S v SOLIDe

Milan Herda, 02 / 2023

SOLID princípy v JavaScripte

S v SOLIDe

Milan Herda, 02 / 2023

SOLID princípy v JavaScripte

S v SOLIDe

Milan Herda, 02 / 2023

SOLID princípy v TypeScripte

a v Reacte

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

SOLID

Nie je iba pre OOP

Princípy sú všeobecne použiteľné

Single Responsibility Principle

SRP

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

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

Modul by mal mať jeden (a iba jeden) dôvod pre svoju zmenu.

Objekt by mal mať jeden (a iba jeden) dôvod pre svoju zmenu.

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

Jednotka kódu

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 funkcie/objektu/modulu)
  • bezpečnejšie úpravy (lebo v kóde nie sú nesúvisiace veci)
  • jednoduchšie pochopenie toho, čo trieda/modul/objekt/funkcia robí
  • ľahká testovateľnosť

Symptómy porušenia princípu pre triedu/objekt

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

Symptómy porušenia princípu pre modul

  • modul má priveľa premenných
  • modul má veľa verejných (exportovaných) funkcií
  • každá funkcia používa iné premenné
  • špecifické úlohy sú delegované na privátne (neexportované) funkcie

Symptómy porušenia princípu pre funkciu

  • funkcia má priveľa riadkov
  • obsahuje komentáre, ktoré pomenovávajú jej časti
  • je možné ju rozdeliť na menšie logické celky
  • funkcia má priveľa úrovní vnorenia vytvorených pomocou podmienok a cyklov (2 úrovne vnorenia sú rozumné maximum)

Ako refaktorovať

  • extrahovať špecifické úlohy do pomocných funkcií
  • identifikovať skrytých kolaborantov
  • preniesť kód skrytých kolaborantov do samostatných modulov
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { Character } from './types';

const commanders: Character[] = [];
const ambassadors: Character[] = [];

const result = await fetch('/api/list.json');
const data = await result.json();

data.forEach((character: Character) => {
    if (character.role === 'commander') {
        commanders.push(character);
    } else if (character.role === 'ambassador') {
        ambassadors.push(character);
    }
});

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
    <React.StrictMode>
        <App commanders={commanders} ambassadors={ambassadors} />
    </React.StrictMode>
);

Tento modul má dve zodpovednosti a teda aj dva dôvody pre zmenu

  • získanie inicializačných dát
  • namontovanie React aplikácie

Refactoring time!

Skúsime modul zrefaktorovať

Stiahnite si zdrojáky

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

async function getInitialData() {
    const commanders: Character[] = [];
    const ambassadors: Character[] = [];

    const result = await fetch('/api/list.json');
    const data = await result.json();

    data.forEach((character: Character) => {
        if (character.role === 'commander') {
            commanders.push(character);
        } else if (character.role === 'ambassador') {
            ambassadors.push(character);
        }
    });

    return {
        commanders,
        ambassadors,
    };
}
  • získanie inicializačných dát

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

function mountApp(
    initialData: {
        commanders: Character[];
        ambassadors: Character[];
    }
) {
    ReactDOM.createRoot(
        document.getElementById('root') as HTMLElement
    ).render(
        <React.StrictMode>
            <App
                commanders={initialData.commanders}
                ambassadors={initialData.ambassadors}
            />
        </React.StrictMode>
    );
}
  • namontovanie React aplikácie

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { Character, getInitialData } from './services/repository/characterRepository';

async function getInitialData() {
    // ...
}

function mountApp(
    initialData: {
        commanders: Character[];
        ambassadors: Character[];
    }
) {
    // ...
}

mountApp(await getInitialData());
  • získanie inicializačných dát
  • namontovanie React aplikácie

Krok 2: identifikovať skrytých kolaborantov

  • Pozrieme sa na nové funkcie a rozhodneme sa, ktorá z nich patrí modulu a ostane v ňom.
  • Zamyslíme sa, aká je zodpovednosť zvyšných funkcií a do akých modulov patria.
  • V našom prípade patria getInitialData aj mountApp do iných modulov

Čo je primárnou zodpovednosťou modulu?

Spustenie aplikácie

Krok 3: presun kolaborantov do samostatných modulov

Funkcia pracuje s údajmi získanými z nejakého zdroja, preto by sme v názve modulu mali mať slovíčko repository

getInitialData presunieme do samostatného modulu

services/character/characterRepository.ts

import { Character } from '@local/types';

export async function getInitialData() {
    const commanders: Character[] = [];
    const ambassadors: Character[] = [];

    const result = await fetch('/api/list.json');
    const data = await result.json();

    data.forEach((character: Character) => {
        if (character.role === 'commander') {
            commanders.push(character);
        } else if (character.role === 'ambassador') {
            ambassadors.push(character);
        }
    });

    return {
        commanders,
        ambassadors,
    };
}

src/services/ui/mountApp.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Character } from '@local/types';
import App from '@local/App';

function mountApp(initialData: { commanders: Character[]; ambassadors: Character[] }) {
    ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
        <React.StrictMode>
            <App commanders={initialData.commanders} ambassadors={initialData.ambassadors} />
        </React.StrictMode>
    );
}

export default mountApp;

src/main.tsx

import { getInitialData } from '@local/services/character/characterRepository';
import mountApp from '@local/services/ui/mountApp';

mountApp(await getInitialData());

Hotovo

Naozaj?

services/repository/characterRepository.ts

import { Character } from '@local/types';

export async function getInitialData() {
    const commanders: Character[] = [];
    const ambassadors: Character[] = [];

    const result = await fetch('/api/list.json');
    const data = await result.json();

    data.forEach((character: Character) => {
        if (character.role === RoleName.Commander) {
            commanders.push(character);
        } else if (character.role === RoleName.ambassador) {
            ambassadors.push(character);
        }
    });

    return {
        commanders,
        ambassadors,
    };
}

Koľko zodpovedností má getInitialData?

Získavame dáta

Filtrujeme dáta

Pre funkciu getInitialData tak postupujeme rovnakým algoritmom, aby sme splnili SRP

  • extrahovať špecifické úlohy do pomocných funkcií
  • identifikovať skrytých kolaborantov
  • preniesť kód skrytých kolaborantov do samostatných modulov

services/repository/characterRepository.ts

import { filterCharactersByRole } from './filterCharacters';

export async function getInitialData() {
    const characters = await loadCharacters();

    return filterCharactersByRole(characters);
}

async function loadCharacters() {
    const result = await fetch('/api/list.json');

    return (await result.json()) as Promise<Character[]>;
}

services/repository/filterCharacters.ts

export function filterCharactersByRole(characters: Character[]) {
    const commanders: Character[] = [];
    const ambassadors: Character[] = [];

    characters.forEach((character: Character) => {
        if (character.role === RoleName.Commander) {
            commanders.push(character);
        } else if (character.role === RoleName.Ambassador) {
            ambassadors.push(character);
        }
    });

    return { commanders, ambassadors };
}

Hotovo

Naozaj?

services/repository/characterRepository.ts

export async function getInitialData() {
    const characters = await loadCharacters();

    return filterCharactersByRole(characters);
}

async function loadCharacters() {
    const result = await fetch('/api/list.json');

    return (await result.json()) as Promise<Character[]>;
}

Čo je toto za názov?

getInitialData?

Dáva to v kontexte characterRepository zmysel?

services/repository/characterRepository.ts

export async function getFilteredCharacters() {
    const characters = await loadCharacters();

    return filterCharactersByRole(characters);
}

async function loadCharacters() {
    const result = await fetch('/api/list.json');

    return (await result.json()) as Promise<Character[]>;
}

src/main.tsx

import { getFilteredCharacters } from '@local/services/repository/characterRepository';
import mountApp from '@local/services/ui/mountApp';

mountApp(await getFilteredCharacters());

Hotovo

Príklad 2

import { useContext, useState } from 'react';
import CharacterContext from './characters/CharacterContext';
import ToastContext, { ToastType } from './toast/ToastContext';
import { RoleName } from './types';

function App() {
    const charactersCtx = useContext(CharacterContext);
    const toastCtx = useContext(ToastContext);

    const [characterName, setCharacterName] = useState<string>('');
    const [characterRole, setCharacterRole] = useState<RoleName>(RoleName.Ambassador);

    const roles = [RoleName.Commander, RoleName.Ambassador];

    return (
        <div>
            <h1>Solid: Single Responsibility Principle</h1>

            <h2>Babylon 5 Characters</h2>

            {toastCtx.toasts.map((toast) => (
                <div key={toast.id} className={`toast toast-${toast.type}`}>
                    {toast.message}
                </div>
            ))}

            <h3>Commanders</h3>
            <ul>
                {charactersCtx.commanders.map((commander) => {
                    return <li key={commander.id}>{commander.name}</li>;
                })}
            </ul>

            <h3>Ambassadors</h3>
            <ul>
                {charactersCtx.ambassadors.map((ambassador) => {
                    return <li key={ambassador.id}>{ambassador.name}</li>;
                })}
            </ul>

            <h3>Add new character</h3>
            <form
                onSubmit={(e) => {
                    e.preventDefault();

                    if (characterName === '') {
                        return;
                    }

                    charactersCtx.addCharacter(characterName, characterRole);
                    toastCtx.addToast(ToastType.Success, 'New character created');

                    setCharacterName('');
                    setCharacterRole(RoleName.Ambassador);
                }}
            >
                <label>Name:</label>{' '}
                <input
                    type="text"
                    value={characterName}
                    onChange={(e) => setCharacterName(e.target.value)}
                />
                <label>Role:</label>{' '}
                <select
                    value={characterRole}
                    onChange={(e) => setCharacterRole(e.target.value as RoleName)}
                >
                    {roles.map((role) => (
                        <option key={role}>{role}</option>
                    ))}
                </select>
                <button type="submit">Add</button>
            </form>
        </div>
    );
}

export default App;

src/App.tsx

Aké sú zodpovednosti tejto funkcie?

  • zobrazuje stránku so zoznamom postáv
  • a zobrazuje ich oddelene podľa ich role
  • a zobrazuje formulár na pridanie novej postavy
  • a kontroluje input pre zadávanie novej postavy
  • a spracúva odoslaný formulár
    • validuje dáta
    • a ukladá do "databázy"
    • a generuje toast
  • a zobrazuje toasty

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

function App() {
    return (
        <div>
            <h1>Solid: Single Responsibility Principle</h1>

            <Toasts />

            <CharactersPage />
        </div>
    );
}

function CharactersPage() {
    const charactersCtx = useContext(CharacterContext);
    return (
        <>
            <h2>Babylon 5 Characters</h2>

            <ListOfCharacters title="Commanders" characters={charactersCtx.commanders} />
            <ListOfCharacters title="Ambassadors" characters={charactersCtx.ambassadors} />

            <NewCharacterForm />
        </>
    );
}

function Toasts() {}

function ListOfCharacters({ title, characters }: { title: string; characters: Character[] }) {}

function NewCharacterForm() {}

export default App;

Krok 2: identifikovať skrytých kolaborantov

Čo ostáva v module a čo doň nepatrí?

Ponecháme funkciu App, všetko ostatné ide do svojich modulov.

Krok 3: presun kolaborantov

  • CharactersPage => src/pages/CharactersPage.tsx
  • Toasts => src/toast/Toasts.tsx
  • ListOfCharacters => src/characters/ListOfCharacters.tsx
  • NewCharacterForm => src/characters/NewCharacterForm.tsx

Hotovo

Naozaj?

src/characters/NewCharacterForm.tsx

function NewCharacterForm() {
    const charactersCtx = useContext(CharacterContext);
    const toastCtx = useContext(ToastContext);

    const [characterName, setCharacterName] = useState<string>('');
    const [characterRole, setCharacterRole] = useState<RoleName>(RoleName.Ambassador);

    const roles = [RoleName.Commander, RoleName.Ambassador];

    return (
        <>
            <h3>Add new character</h3>
            <form
                onSubmit={(e) => {
                    e.preventDefault();

                    if (characterName !== '') {
                        charactersCtx.addCharacter(characterName, characterRole);
                        toastCtx.addToast(ToastType.Success, 'New character created');
                        setCharacterName('');
                        setCharacterRole(RoleName.Ambassador);
                    }
                }}
            >
                <label>Name:</label>{' '}
                <input
                    type="text"
                    value={characterName}
                    onChange={(e) => setCharacterName(e.target.value)}
                />
                <label>Role:</label>{' '}
                <select
                    value={characterRole}
                    onChange={(e) => setCharacterRole(e.target.value as RoleName)}
                >
                    {roles.map((role) => (
                        <option key={role}>{role}</option>
                    ))}
                </select>
                <button type="submit">Add</button>
            </form>
        </>
    );
}
  • vykreslenie formuláru
  • "spracovanie odoslania"
  • validácia
  • samotné spracovanie
  • reset hodnôt
  • manažment stavu inputov

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

function NewCharacterForm() {
    // začiatok funkcie bezo zmeny

    function validateForm() {
        if (characterName === '') {
            return false;
        }

        return true;
    }

    function resetForm() {
        setCharacterName('');
        setCharacterRole(RoleName.Ambassador);
    }

    function submitHandler(event: FormEvent) {
        event.preventDefault();

        if (validateForm()) {
            charactersCtx.addCharacter(characterName, characterRole);
            toastCtx.addToast(ToastType.Success, 'New character created');
            resetForm();
        }
    }

    return (
        <>
            <h3>Add new character</h3>
            <form onSubmit={submitHandler}>
               {/* zvysok formularu bezo zmeny */}
            </form>
        </>
    );
}

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

function useCharacterForm(onSuccess?: (name: string, role: RoleName) => void) {
    const [characterName, setCharacterName] = useState<string>('');
    const [characterRole, setCharacterRole] = useState<RoleName>(RoleName.Ambassador);

    function validateForm() {
        // ostal pôvodný kód
    }

    function resetForm() {
        // ostal pôvodný kód
    }

    function submitHandler(event: FormEvent) {
        event.preventDefault();

        if (validateForm()) {
            onSuccess?.(characterName, characterRole);
            resetForm();
        }
    }

    return {
        characterName: {
            value: characterName,
            setValue: setCharacterName,
        },
        characterRole: {
            value: characterRole,
            setValue: setCharacterRole,
        },
        submitHandler,
    };
}

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

function NewCharacterForm() {
    const charactersCtx = useContext(CharacterContext);
    const toastCtx = useContext(ToastContext);

    const roles = [RoleName.Commander, RoleName.Ambassador];

    const form = useCharacterForm((characterName, characterRole) => {
        charactersCtx.addCharacter(characterName, characterRole);
        toastCtx.addToast(ToastType.Success, 'New character created');
    });

    return (
        <>
            <h3>Add new character</h3>
            <form onSubmit={form.submitHandler}>
                <label>Name:</label>{' '}
                <input
                    type="text"
                    value={form.characterName.value}
                    onChange={(e) => form.characterName.setValue(e.target.value)}
                />
                <label>Role:</label>{' '}
                <select
                    value={form.characterRole.value}
                    onChange={(e) => form.characterRole.setValue(e.target.value as RoleName)}
                >
                    {roles.map((role) => (
                        <option key={role}>{role}</option>
                    ))}
                </select>
                <button type="submit">Add</button>
            </form>
        </>
    );
}

Krok 2: identifikovať skrytých kolaborantov

  • useCharacterForm by mohlo byť v samostatnom module
  • je to ale pomerne úzko prepojené na zobrazovanie
  • možno by si zobrazenie formuláru aj nový hook zaslúžili vlastný priečinok (src/characters/NewCharacterForm)

Krok 3: presun kolaborantov

  • useCharacterForm => src/characters/NewCharacterForm/useCharacterForm.ts
  • NewCharacterForm => src/characters/NewCharacterForm/index.tsx

Hotovo

Naozaj?

function useCharacterForm(onSuccess?: (name: string, role: RoleName) => void) {
    const [characterName, setCharacterName] = useState<string>('');
    const [characterRole, setCharacterRole] = useState<RoleName>(RoleName.Ambassador);

    function validateForm() {
        if (characterName === '') {
            return false;
        }

        return true;
    }

    function resetForm() {
        setCharacterName('');
        setCharacterRole(RoleName.Ambassador);
    }

    function submitHandler(event: FormEvent) {
        event.preventDefault();

        if (validateForm()) {
            onSuccess?.(characterName, characterRole);
            resetForm();
        }
    }

    return {
        characterName: {
            value: characterName,
            setValue: setCharacterName,
        },
        characterRole: {
            value: characterRole,
            setValue: setCharacterRole,
        },
        submitHandler,
    };
}

Koľko dôvodov na zmenu vieme nájsť pre useCharacterForm?

  • úprava defaultného mena
  • úprava validácie mena
  • úprava resetu mena
  • úprava defaultnej role
  • úprava validácie role
  • úprava resetu role

Koľko dôvodov na zmenu viem nájsť pre useCharacterForm?

  • úprava defaultného mena
  • úprava validácie mena
  • úprava resetu mena
  • úprava defaultnej role
  • úprava validácie role
  • úprava resetu role

správa mena

správa role

function useNameInput() {
    const [characterName, setCharacterName] = useState('');

    return {
        value: characterName,
        setValue: setCharacterName,
        validate: () => characterName !== '',
        reset: () => setCharacterName(''),
    };
}

function useRoleInput() {
    const [characterRole, setCharacterRole] = useState<RoleName>(RoleName.Ambassador);

    return {
        value: characterRole,
        setValue: setCharacterRole,
        validate: () => true,
        reset: () => setCharacterRole(RoleName.Ambassador),
    };
}

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

function useCharacterForm(onSuccess?: (name: string, role: RoleName) => void) {
    const characterName = useNameInput();
    const characterRole = useRoleInput();

    function validateForm() {
        return (characterName.validate() && characterRole.validate());
    }

    function resetForm() {
        characterName.reset();
        characterRole.reset();
    }

    return {
        characterName,
        characterRole,
        submitHandler: (event: FormEvent) => {
            event.preventDefault();

            if (validateForm()) {
                onSuccess?.(characterName.value, characterRole.value);
                resetForm();
            }
        },
    };
}

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

Krok 2: identifikovať skrytých kolaborantov

  • useNameInput a useRoleInput by teoreticky mohli byť v samostatných moduloch
  • ale zatiaľ sú to len dve kratučké funkcie a tak benefit z presunu nie je veľký

Krok 3: presun kolaborantov

Nemusíme v tomto prípade riešiť

Hotovo

Príklad 3

import type { Station } from './types';

export function createFromData(data: Record<string, string>): Station {
    const { id, name } = data;

    return {
        getId: function () {
            return id;
        },
        getName: function () {
            return name;
        },
        save: async function () {
            const result = await fetch(`/api/station/${id}`, {
                method: 'POST',
                body: JSON.stringify({ id, name }),
                headers: {
                    'Content-Type': 'application/json',
                },
            });

            if (result.status === 201) {
                return true;
            } else {
                throw new Error(`Server responded with code ${result.status}`);
            }
        },
    };
}

src/station/domain/entity/station.ts

Aké sú zodpovednosti objektu?

  • poskytuje dáta o stanici (pomocou getterov)
  • zabezpečuje ukladanie dát na backend

Krok 1: extrahovať špecifické úlohy do pomocných funkcií

Už je to pekne oddelené

Krok 2: identifikovať skrytých kolaborantov

  • getId a getName patria objektu Station
  • so save to je ťažšie
    • pokiaľ používame anemický model, tak táto metóda vôbec nepatrí do objektu
    • ak používame DDD, tak jej implementácia musí íst preč, ale metóda samotná môže ostať

Krok 3: presun kolaborantov

V prípade anemického modelu aj DDD presunieme implementáciu metódy save do repozitáru

Ukážme si DDD prístup

import { Station } from '../entity/types';

export type PersistStationFunc = (station: Station) => Promise<boolean>;

src/station/domain/repository/stationRepository.ts

import { Station } from '@local/station/domain/entity/types';
import { PersistStationFunc } from '@local/station/domain/repository/stationRepository';

export const saveStation: PersistStationFunc = async function saveStation(station: Station) {
    const result = await fetch(`/api/station/${station.getId()}`, {
        method: 'POST',
        body: JSON.stringify({ id: station.getId(), name: station.getName() }),
        headers: {
            'Content-Type': 'application/json',
        },
    });

    if (result.status === 201) {
        return true;
    } else {
        throw new Error(`Server responded with code ${result.status}`);
    }
  
    return false;
};

src/station/infrastructure/repository/stationRepository.ts

import type { Station } from './types';
import type { PersistStationFunc } from '../repository/stationRepository';

export function createFromData(data: Record<string, string>): Station {
    const { id, name } = data;

    return {
        getId: function () {
            return id;
        },
        getName: function () {
            return name;
        },
        save: async function (persist: PersistStationFunc) {
            if (await persist(this)) {
                return true;
            }

            return false;
        },
    };
}

src/station/domain/entity/station.ts

import { PersistStationFunc } from '../repository/stationRepository';

export interface Station {
    getId: () => string;
    getName: () => string;
    save: (persist: PersistStationFunc) => Promise<boolean>;
}

src/station/domain/entity/types.ts

Hotovo

Naozaj?

Môžeme ešte rozoberať rôzne pohľady a implementácie DDD

  • naozaj patrí metóda save do entity?
  • nemôžeme dať persistovaciu funkciu priamo ako závislosť createStation?
  • ...

Ale to už nepatrí do debaty

o Single Responsibility Principle

Opakovanie

Single Responsibility Principle

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

Symptómy porušenia princípu

  • jednotka má veľa properties/premenných
  • jednotka má veľa public funkcií
  • každá metóda používa iné properties/premenné
  • špecifické úlohy sú delegované na privátne funkcie

Námietka voči Single Responsibility Principle

SRP nás núti vytvárať priveľa funkcií a súborov a tým sa stráca prehľadnosť
  • áno, vzniká viacej funkcií a súborov
  • 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é

Ďakujem za pozornosť

S v SOLIDe (JavaScript)

By Milan Herda

S v SOLIDe (JavaScript)

  • 166