úterý 1. února 2011

Princip otevřenosti a uzavřenosti - Open Closed Principle

Otevřený nebo uzavřený?

Princip otevřenosti a uzavřenosti (Open Closed Principle - OCP) je druhým základním principem definovaným objektovou návrhovou metodologií SOLID. Jeho definice zní:

Programové entity (třídy, moduly, funkce, apod.) by měly být otevřeny pro rozšiřování, ale uzavřeny pro modifikaci.

Pojďme si definici rozložit na jednotlivé podčásti:

  • Otevřený pro rozšiřování. Chování entity může být dále rozšiřováno. Tím je zajištěna reakce na změnu požadavků v aplikaci.
  • Uzavřený pro modifikaci. Zdrojový kód entity je nedotknutelný. Nikdo nemá povoleno kód změnit.
Možná se Vám teď začínají protáčet mozkošrouby a snažíte se přijít na to, jak tento, na první pohled protichůdný princip, aplikovat v praxi. Nebojte se, rozumím Vám. Vydržte a čtěte dál.

Klíčem je abstrakce

Pokud použijeme přímou vazbu mezi konkrétními třídami, které jsou ve vztahu klient - server, stává se klientská třída přímo závislá na serverové. Pokud potřebujeme použít jinou serverou třídu, musíme otevřít klientskou třídu a změnit název serverové třídy. Takový klient je uzavřený vůči změnám, viz. Obrázek 1. Tento návrh porušuje princip OCP.

Na Obrázku 2. je návrh pozměněn tak, že mezi klientskou a serverovou třídu je vložena abstraktní třída. Klient nepracuje přímo se serverovou třídou, ale s abstrakcí. Za abstraktní třídou je ukryta konkrétní podtřída. Případnou změnou podtřídy není ovlivněn klient, není jej potřeba otevírat a měnit. Klient je otevřený. Návrh respektuje princip OCP.

V programovacích jazycích se pro zajištění abstrakce využívá buď abstraktní třída (abstract class) nebo rozhraní (interface). Velké množství návrhových vzorů také vzniklo s primární motivací zajištění principu OCP.

Zkušený objektový návrhář dokáže predikovat chování systému natolik, že odhadne pevné body systému a pro ně navrhne dostatečně abstraktní entity. U abstraktních entit se nepředpokládají časté změny v budoucnu (uzavřenost), naopak se nechává prostor pro různé implementace ukryté za abstraktní vrstvou (otevřenost).

Příklad - ověřování uživatelů

Vývojář řeší požadavek na vytvoření variabilního mechanismu ověřování uživatelů. Sledujme jeho myšlenkové pochody a následné prozření. Není úplným nováčkem v objektovém návrhu, proto se rozhodne každý typ ověření uživatele naimplementovat do samostatné třídy. Aktuálně je požadováno ověřování vůči databázi a vůči Active Directory. Vytvoří tedy třídy OvereniUzivateleVuciDatabazi a OvereniUzivateleActiveDirectory:

public class OvereniUzivateleVuciDatabazi
{
    public void Overit(String pripojovaciRetezec, String uzivatelskeJmeno, String heslo)
    {
        // ... implementace databázového ověření
    }
}

public class OvereniUzivateleActiveDirectory
{
    public void Overit(String nazevDomeny, String uzivatelskeJmeno, String heslo)
    {
        // ... implementace doménového ověření přes Active Directory
    }
}

Tyto ověřovací moduly budou využívány ve třídě SpravceUzivatelskychOvereni. SpravceUzivatelskychOvereni je klientskou třídou vůči třídám OvereniUzivateleVuciDatabazi a OvereniUzivateleActiveDirectory. Třída pro správu uživatelských ověření zvěřejňuje metodu OveritUzivatele(), která vytváří instanci příslušné ověřovací třídy a následně vyvolá její metodu pro ověření:

public class SpravceUzivatelskychOvereni
{
    public void OveritUzivatele(String uzivatelskeJmeno, String heslo)
    {
        TypOvereniUzivatele typOverovani = 
            (TypOvereniUzivatele)Aplikace.Nastaveni["TypOvereniUzivatele"];

        switch (typOverovani)
        {
            case TypOvereniUzivatele.VuciDatabazi:
                OvereniUzivateleVuciDatabazi overeniDatabaze = new OvereniUzivateleVuciDatabazi();
                overeniDatabaze.Overit(Aplikace.Nastaveni["PripojovaciRetezec"] as String, 
                    uzivatelskeJmeno, heslo);
                break;

            case TypOvereniUzivatele.ActiveDirectory:
                OvereniUzivateleActiveDirectory overeniActiveDirectory = new OvereniUzivateleActiveDirectory();
                overeniActiveDirectory.Overit(Aplikace.Nastaveni["NazevDomeny"] as String, 
                    uzivatelskeJmeno, heslo);
                break;
        }
    }
}

public enum TypOvereniUzivatele 
{
    VuciDatabazi,
    ActiveDirectory
}

Jak asi tušíte, došlo tady k porušení principu OCP. Problematická je metoda OveritUzivatele() třídy SpravceUzivatelskychOvereni, která používá konkrétní třídy OvereniUzivateleVuciDatabazi a OvereniUzivateleActiveDirectory. Je pravděpodobné, že v budoucnu vyvstane požadavek na další typ ověřovacího modulu. Třídu SpravceUzivatelskychOvereni bude nutné otevřít a dopsat další větev příkazu switch. Každá podobná změna vynutí modifikaci kódu, který ovlivní i kód související s klientským použitím třídy OvereniUzivateleVuciDatabazi.

Příkaz switch je obvykle indikátorem chybného návrhu. Na jednom místě se autor pokouší sloučit rozhodovací a výkonnou logiku. Výkonná logika by měla být řešena v příslušné podtřídě a rozhodovací při vzniku objektu příslušné třídy.

Příklad - ověřování uživatelů s dodržením OCP

Pojďme se podívat na návrh očima návrháře, který zná a respektuje OCP. Musíme si uvědomit, co jsou v případě ověřování uživatelů pevné body a co variabilní části. Jak jsme uvedli výše, pro zajištění principu OCP se využívá přidání vrstvy s abstrakcí. Pevným bodem je to, že každý ověřovací modul musí mít metodu, která provede vlastní ověření. Pro tyto účely vytvoříme abstraktní třídu, která bude obsahovat pouze jednu abstraktní metodu Overit():

public abstract class OvereniUzivateleAbstract
{
    public abstract void Overit(IParametryOvereniUzivatele parametry);
}

Metoda Overit() je sice nezávislá na způsobu ověřování, ale ve své implementaci vyžaduje různé vstupní argumenty. Pro ověření vůči databázi potřebuje připojovací řetězec, uživatelské jméno a heslo. Pro ověření vůči Active Directory zase název domény, uživatelské jméno a heslo. Musíme proto využít obecnější mechanismus pro předávání variabilních vstupů. Potřebujeme další abstrakci. Tentokrát zvolíme rozhraní, pojmenujeme jej IParametryOvereniUzivatele a nadefinujeme jednoduše takto:

public interface IParametryOvereniUzivatele
{
}

Parametry ověřování vůči databázi mohou být implementovány například:

public class ParametryOvereniUzivateleVuciDatabazi : IParametryOvereniUzivatele
{
    private String pripojovaciRetezec;
    private String uzivatelskeJmeno;
    private String heslo;

    public ParametryOvereniUzivateleVuciDatabazi(String pripojovaciRetezec, String uzivatelskeJmeno, String heslo)
    {
        this.pripojovaciRetezec = pripojovaciRetezec;
        this.uzivatelskeJmeno = uzivatelskeJmeno;
        this.heslo = heslo;
    }

    public string PripojovaciRetezec
    {
        get { return pripojovaciRetezec; }
    }

    public string UzivatelskeJmeno
    {
        get { return uzivatelskeJmeno; }
    }

    public string Heslo
    {
        get { return heslo; }
    }
}

Pokud máme abstraktní třídu OvereniUzivateleAbstract a implementaci rozhraní s parametry pro ověřování vůči databázi, můžeme naimplementovat vlastní ověřování vůči databázi:

public class OvereniUzivateleVuciDatabazi : OvereniUzivateleAbstract
{
    public override void Overit(IParametryOvereniUzivatele parametry)
    {
        if (!(parametry is ParametryOvereniUzivateleVuciDatabazi))
        {
            throw new ArgumentException("Parametr není typu ParametryOvereniUzivateleVuciDatabazi.");
        }

        ParametryOvereniUzivateleVuciDatabazi parametryOvereni = 
            parametry as ParametryOvereniUzivateleVuciDatabazi;

        // ... implementace databázového ověření
    }
}

Všimněte si, že na vstupu metody Overit() je kvůli abstrakci argument typu IParametryOvereniUzivatele, který si ihned po validaci přetypujeme na ParametryOvereniUzivateleVuciDatabazi. V tomto okamžiku máme vše potřebné pro ověření uživatele přes databázi. Podobným způsobem by se naimplementovalo ověření vůči ActiveDirectory nebo jiné, které má vlastní specifika.

Nakonec se dostáváme ke třídě SpravceUzivatelskychOvereni, která nám vynutila tuto refaktorizaci. Třída se v našem případě zjednodušila na absolutní minimum. Metoda OveritUzivatele() dostane dvě abstrakce - první pro ověřovací třídu a druhou pro parametry příslušného ověření. Nad těmito abstrakcemi provede vyvolání ověření:

public class SpravceUzivatelskychOvereni
{
    public void OveritUzivatele(OvereniUzivateleAbstract overeniUzivatele, IParametryOvereniUzivatele parametry)
    {
        overeniUzivatele.Overit(parametry);
    }
}

Přidání dalšího ověřovacího modulu v tomto návrhu znamená, že není ovlivněn žádný z existujících modulů ani třída SpravceUzivatelskychOvereni.

Můžete se ptát, kde a jak vznikají instance ověřovacích tříd a jejich parametrů? Je vhodné využít některý z návrhových vzorů pro vytváření objektů. Konfigurace výběru třídy pro ověření uživatelů může být například v konfiguračním souboru aplikace.

Princip OCP jako základ pro další mechanismy

Princip OCP má ve světě objektového návrhu zcela zásadní význam. Velké množství souvisejících mechanismů bylo motivováno právě tímto principem:

  • Princip jednotného přístupu (Uniform Access Principle). Všechny služby poskytované modulem (třídou) by měly být dostupné přes jednotnou notaci. Notace je nezávislá na tom, zda-li je služba implementována nad úložištěm nebo jako vypočítavaná hodnota. V notaci C# se jedná o zpřístupnění obsahu objektu přes property.
  • Návrhy řízené daty (Data-Driven Designs). Pokrývají širokou rodinu technik jako je parametrizace systému z externích zdrojů, metadata pro mapování ORM, skiny oken, apod. Systém je chráněn proti vlivu dat, metadat a deklarativních variant tím, že se odloučí variabilní části ven ze systému a samotný systém je uzavřený.
  • Interpretem řízený návrh (Interpreter-Driven Design). V návrzích tohoto typu čte interpreter pravidla z externího zdroje (skriptu) a provádí je. Používá se v souvislosti s virtuálními stroji, neuronovými sítěmi, množinami omezujících podmínek, apod. Takto lze parametrizovat systém pomocí externí logiky. Systém je opět chráněn externalizací logiky a čtením skrze interpret.

Někdy se pro OCP používá ekvivalent Protected Variations.

Další zdroje a ukázky

Generická modifikace příkladu

Kolega Radek mi navrhl, abych zkusil použít v příkladě generiku. Upravený kód přikládám. Konstrukce ve třídě SpravceUzivatelskychOvereni nyní vypadají méně přehledně, ale zůstává otázkou, zda-li nově tuto třídu vůbec potřebujeme. Ale to už je předmětem diskuze nad rámec principu OCP.

public interface IParametryOvereniUzivatele
{
}

public abstract class OvereniUzivateleAbstract<TParametryOvereniUzivatele>
    where TParametryOvereniUzivatele : IParametryOvereniUzivatele
{
    public abstract void Overit(TParametryOvereniUzivatele parametry);
}

public class ParametryOvereniUzivateleVuciDatabazi : IParametryOvereniUzivatele
{
    private String pripojovaciRetezec;
    private String uzivatelskeJmeno;
    private String heslo;

    public ParametryOvereniUzivateleVuciDatabazi(String pripojovaciRetezec, String uzivatelskeJmeno, String heslo)
    {
        this.pripojovaciRetezec = pripojovaciRetezec;
        this.uzivatelskeJmeno = uzivatelskeJmeno;
        this.heslo = heslo;
    }

    public string PripojovaciRetezec
    {
        get { return pripojovaciRetezec; }
    }

    public string UzivatelskeJmeno
    {
        get { return uzivatelskeJmeno; }
    }

    public string Heslo
    {
        get { return heslo; }
    }
}

public class OvereniUzivateleVuciDatabazi : OvereniUzivateleAbstract<ParametryOvereniUzivateleVuciDatabazi>
{
    public override void Overit(ParametryOvereniUzivateleVuciDatabazi parametry)
    {            
        // ... implementace databázového ověření
    }
}

public class SpravceUzivatelskychOvereni
{
    public void OveritUzivatele<TParametryOvereniUzivatele>(
        OvereniUzivateleAbstract<TParametryOvereniUzivatele> overeniUzivatele, TParametryOvereniUzivatele parametry)
        where TParametryOvereniUzivatele : IParametryOvereniUzivatele
    {
        overeniUzivatele.Overit(parametry);
    }
}

Žádné komentáře:

Okomentovat