S v SOLIDe

Milan Herda, 03 / 2018

Kto som

programátor (PHP, JavaScript), hráč spoločenských hier, fanúšik sci-fi, wannabe autor browser hier (feudarium.com)

Profesia, FatChilli, Porada, BlueOrange, NOV

profesia.sk, domelia.sk, rtvs.sk, reality.sme.sk, living.sk...

O čom budeme hovoriť

  • Čo je SOLID
  • Single Responsibility Principle
  • Výhody Single Responsibility
  • Ako rozpoznať porušenia princípu
  • Ako refaktorovať, aby sme dodržali princíp

SOLID

  • Single Responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Skratka predstavujúca 5 základných princípov dobrého softvérového návrhu

Single Responsibility Principle

Trieda by mala mať jeden (a iba jeden) dôvod pre svoju zmenu.

Výhody dodržiavania

Single Responsibility Principle

  • jednoduchšie úpravy (ľahkou zmenou kódu alebo výmenou celej triedy)
  • bezpečnejšie úpravy (lebo v kóde nie sú nesúvisiace veci)
  • jednoduchšie pochopenie toho, čo trieda robí
  • ľahká testovateľnosť

Symptómy porušenia princípu

  • trieda má veľa properties
  • trieda má veľa public metód
  • každá metóda používa iné properties
  • špecifické úlohy sú delegované na privátne metódy

Ako refaktorovať

  • extrahovať špecifické úlohy do privátnych metód
  • identifikovať skrytých kolaborantov
  • preniesť kód skrytých kolaborantov do samostatných tried
class ConfirmationMailer {
    private TemplatingInterface $templating;

    private TranslatorInterface $translator;

    private MailerInterface $mailer;

    public function __construct(TemplatingInterface $templating, TranslatorInterface $translator, MailerInterface $mailer) {
        $this->templating = $templating;
        $this->translator = $translator;
        $this->mailer     = $mailer;
    }

    public function sendToUser(User $user) {
        $subject = $this->translator->translate('Confirm your email address');

        $body = $this->templating->render(
            'confirmationEmail.tpl',
            [
                'user' => $user,
            ]
        );

        $message = new Message($subject, $body);

        $message->setTo($user->getEmailAddress());

        $this->mailer->send($message);
    }
}

Táto trieda má dve zodpovednosti a teda aj dva dôvody pre zmenu

  • odoslanie emailu
  • vytvorenie emailu

Refactoring time!

Skúsime triedu zrefaktorovať

Stiahnite si zdrojáky

Krok 1: extrahovať špecifické úlohy do privátnych metód

public function sendToUser(User $user) {
    $message = $this->createMessageForUser($user);

    $this->sendMessage($message);
}

private function createMessageForUser(User $user) {
    $subject = $this->translator->translate('Confirm your email address');

    $body = $this->templating->render(
        'confirmationEmail.tpl',
        [
            'user' => $user,
        ]
    );

    $message = new Message($subject, $body);

    $message->setTo($user->getEmailAddress());

    return $message;
}

private function sendMessage(MessageInterface $message) {
    $this->mailer->send($message);
}
  • odoslanie emailu
  • vytvorenie emailu

Krok 2: identifikovať skrytých kolaborantov

Pozrieme sa na nové privátne metódy a rozhodneme sa, ktorá z nich patrí triede a ostane.

Zamyslíme sa, aká je zodpovednosť zvyšných metód a do akých tried patria.

V našom prípade patrí createMessageForUser do inej triedy

Krok 3: presun kolaborantov do samostatných tried

Názov metódy nám hovorí, že sa niečo vytvára. Takže nová trieda bude továrničkou (factory)

createMessageForUser presunieme do samostatnej triedy

Výsledok: Factory

class ConfirmationMessageFactory
{
    private TemplatingInterface $templating;

    private TranslatorInterface $translator;

    public function __construct(TemplatingInterface $templating, TranslatorInterface $translator)
    {
        $this->templating = $templating;
        $this->translator = $translator;
    }

    public function createMessageForUser(User $user): MessageInterface
    {
        $subject = $this->translator->translate('Confirm your email address');

        $body = $this->templating->render(
            'confirmationEmail.tpl',
            [
                'user' => $user,
            ]
        );

        $message = new Message($subject, $body);

        $message->setTo($user->getEmailAddress());

        return $message;
    }
}

Výsledok: Mailer

class ConfirmationMailer
{
    private ConfirmationMessageFactory $confirmationMessageFactory;

    private MailerInterface $mailer;

    public function __construct(
        ConfirmationMessageFactory $confirmationMessageFactory,
        MailerInterface $mailer
    ) {
        $this->confirmationMessageFactory = $confirmationMessageFactory;
        $this->mailer                     = $mailer;
    }

    public function sendToUser(User $user)
    {
        $message = $this->confirmationMessageFactory->createMessageForUser($user);

        $this->mailer->send($message);
    }
}

Príklad 2

class Building
{
    private int $id;
    private int $type;
    private string $name;
    private int $level;
    private TownInterface $town;
    private array $priceTable;
    private DatabaseConnectionInterface $databaseConnection;

    public function __construct(DatabaseConnectionInterface $databaseConnection, array $priceTable) { /* ... */ }

    public function setId($id) { /* ... */ }
    public function getId() { /* ... */ }

    public function setType($type) { /* ... */ }
    public function getType() { /* ... */ }

    public function setName($name) { /* ... */ }
    public function getName() { /* ... */ }

    public function setLevel($level) { /* ... */ }
    public function getLevel() { /* ... */ }

    public function setTown(TownInterface $town) { /* ... */ }
    public function getTown() { /* ... */ }

    public function getPrice($level = 0) { /* ... */ }

    public function build() { /* ... */ }

    public function upgrade() { /* ... */ }
}

Aké sú zodpovednosti triedy?

  • uchovávať informácie o budove (id, typ, level...)
  • poskytovať informáciu o cene budovy pre rôzny level
  • postavenie budovy
  • upgrade budovy

Krok 1: extrahovať špecifické úlohy do privátnych metód

Nemusíme robiť, trieda je celkom pekne rozdelená

Krok 2: identifikovať skrytých kolaborantov

  • informácie o budove necháme v triede Building
  • tabuľka cien by mala byť samostatným objektom
  • práca s databázou je zodpovednosťou inej triedy (repozitár)
  • prípadne podľa použitia môžeme zvážiť vytvorenie tried zabezpečujúcich stavbu budov

Krok 3: presun kolaborantov do samostatných tried

Vytvoríme nové triedy

  • tabuľku cien budovy nazvanú napr. BuildingPriceTable
  • Repozitár pre databázové operácie k budove,  BuildingRepository

Výsledok: BuildingPriceTable

class BuildingPriceTable
{
    /** @var array<int,ResourcesInterface> */
    private array $pricesPerLevel;

    public function __construct(array $pricesPerLevel)
    {
        $this->pricesPerLevel = $pricesPerLevel;
    }

    public function getPrice(int $level): ResourcesInterface
    {
        if (array_key_exists($level, $this->priceTable)) {
            return $this->priceTable[$level];
        }

        throw new InvalidLevelException();
    }
}

Výsledok: BuildingRepository

class BuildingRepository
{
    private DatabaseConnectionInterface $databaseConnection;

    public function __construct(DatabaseConnectionInterface $databaseConnection)
    {
        $this->databaseConnection = $databaseConnection;
    }

    public function buildNewBuilding(int $type, int $townId): int
    {
        $id = $this->databaseConnection->insert(
            'building',
            [
                'type%i'    => $type,
                'level%i'   => 1,
                'town_id%i' => $townId,
            ]
        );

        return $id;
    }

    public function upgradeBuilding(int $buildingId)
    {
        $this->databaseConnection
            ->update(
                'building',
                [
                    'level%sql' => 'level + 1',
                ]
            )
            ->where('id = %i', $buildingId);
    }
}

Výsledok: Building

class Building
{
    private int $id;

    private int $type;

    private string $name;

    private int $level;

    private TownInterface $town;

    public function setId($id) { /* ... */ }
    public function getId() { /* ... */ }

    public function setType($type) { /* ... */ }
    public function getType() { /* ... */ }

    public function setName($name) { /* ... */ }
    public function getName() { /* ... */ }

    public function setLevel($level) { /* ... */ }
    public function getLevel() { /* ... */ }

    public function setTown(TownInterface $town) { /* ... */ }
    public function getTown() { /* ... */ }
}

Príklad 3

public function editArticleAction(
    int $articleId,
    RequestInterface $request
): ResponseInterface {
    $query = $this->databaseConnection
        ->select('id, title, text')
        ->from('article')
        ->where('id = %i', $articleId);

    $result = $query->execute();

    $row = $result->fetchRow();

    $article = null;

    if ($row) {
        $article = new Article();
        $article->setId($row['id'])
            ->setTitle($row['title'])
            ->setText($row['text']);
    }

    if (!$article) {
        throw new Status404NotFoundException();
    }

    if ($request->isMethod('POST')) {
        $title = $request->getParam('title', '');
        $text  = $request->getParam('text', '');

        if (
            (trim($title) !== '')
            && (trim($text) !== '')
        ) {
            $article->setTitle($title)
                ->setBody($body);

            $this->databaseConnection
                ->update(
                    'article',
                    [
                        'title%s' => $title,
                        'text%s'  => $text,
                    ]
                )
                ->where('id = %i', $articleId)
                ->execute();

            if (!array_key_exists('flash_messages', $_SESSION)) {
                $_SESSION['flash_messages'] = [];
            }

            if (!isset($_SESSION['flash_messages']['info'])) {
                $_SESSION['flash_messages']['info'] = [];
            }

            $_SESSION['flash_messages']['info'][] = 
                $this->translator->trans('Article was updated');

            $detailUrl = '/article/' . $article->getTitle();

            return new RedirectResponse($detailUrl);
        }
    }

    $html = $this->templating->render(
        'article/edit.html.twig',
        [
            'article' => $article,
        ]
    );

    return new Response($html);
}

Aké sú zodpovednosti triedy?

  • vyhľadať v databáze článok podľa ID
  • vytvoriť inštanciu triedy Article
  • naplniť článok hodnotami z odoslaného formuláru
  • upraviť článok v databáze
  • zapísať flash správu
  • urobiť redirect
  • vyrendrovať stránku s editačným formulárom

Postup refaktoringu

Postup je rovnaký, ale príliš dlhý pre slajdy :)

Urobíme to spoločne

Opakovanie

Trieda by mala mať jeden (a iba jeden) dôvod pre svoju zmenu.

Symptómy porušenia princípu

  • trieda má veľa properties
  • trieda má veľa public metód
  • každá metóda používa iné properties
  • špecifické úlohy sú delegované na privátne metódy

Námietka voči Single Responsibility Principle

SRP nás núti vytvárať priveľa tried a tým sa stráca prehľadnosť
  • áno, vzniká viacej tried
  • sú však malé a ich zodpovednosti jasné
  • keď sú dobre pomenované, netreba študovať ich kód, čiže prehľadnosť stúpa
  • sú znovupoužiteľné

Viac informácií:

Matthias Noback - Principles of Package Design

Ďakujem za pozornosť