O v SOLIDe

Milan Herda, 04 / 2023

SOLID princípy v JavaScripte

O čom budeme hovoriť

  • Čo je SOLID
  • Open-Closed Principle
  • Výhody Open-Closed Principle
  • 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é

Open-Closed Principle

OCP

Open-Closed Principle

OCP

Jednotka kódu by mala byť otvorená pre rozširovanie a zároveň uzatvorená pre zmeny.

Jednotka kódu by mala byť otvorená pre rozširovanie a zároveň uzatvorená pre zmeny.

Trieda by mala byť otvorená pre rozširovanie a zároveň uzatvorená pre zmeny.

Objekt by mal byť otvorený pre rozširovanie a zároveň uzatvorený pre zmeny.

Modul by mal byť otvorený pre rozširovanie a zároveň uzatvorený pre zmeny.

Funkcia by mala byť otvorená pre rozširovanie a zároveň uzatvorená pre zmeny.

Jednotka kódu by mala byť otvorená pre rozširovanie a zároveň uzatvorená pre zmeny.

Mali by sme byť schopní rozšíriť správanie aj bez potreby modifikácie kódu.

Výhody dodržiavania

Open-Closed Principle

  • možnosť zmeniť správanie časti kódu aj bez jej úpravy
  • bezpečnejšie úpravy (lebo nemeníme kód)

Symptómy porušenia princípu

  • v kóde sú podmienky pre určenie stratégie (typicky napr. switch)
  • podobné podmienky sa opakujú na viacerých miestach v kóde
  • kód obsahuje hardkódovaný názov súboru, emailovú adresu alebo inú skalárnu hodnotu
  • trieda obsahuje natvrdo nakódované názvy iných tried (v poli, podmienkach, stringoch)
  • vo vnútri triedy sa vytvárajú objekty pomocou new

Ako refaktorovať

  • zabezpečiť spĺňanie Single Responsibility Principle
  • identifikovať všeobecné a špecifické časti úlohy
  • oddeliť špecifické a všeobecné úlohy
  • špecifické časti preniesť von
  • prepojiť všeobecnú a špecifickú časť pomocou "konfigurácie"
function mountApp(
    initialData: {
        commanders: Character[];
        ambassadors: Character[];
    }
) {
    ReactDOM.createRoot(
        document.getElementById('root') as HTMLElement
    ).render(
        <App initialData={initialData} />
    );
}
function mountApp(
    initialData: {
        commanders: Character[];
        ambassadors: Character[];
    }
) {
    ReactDOM.createRoot(
        document.getElementById('root') as HTMLElement
    ).render(
        <App initialData={initialData} />
    );
}

Zmeňte element, do ktorého sa rendruje.

Nie je to možné bez modifikácie funkcie.

Refactoring time!

Stiahnite si zdrojáky

Krok 1: Zabezpečiť spĺňanie Single-Responsibility princípu

Funkcia už spĺňa SRP

Krok 2: Identifikovať všeobecné a špecifické časti úlohy

ID elementu je špecifická časť

A vlastne aj element samotný je špecifická časť

Krok 3: Oddeliť špecifické a všeobecné úlohy

Element nebude získavaný vo vnútri funkcie

Krok 4: Špecifické časti preniesť von

Element sa získa mimo tela funkcie a funkcia ho len použije

Krok 5: Prepojiť všeobecnú a špecifickú časť pomocou "konfigurácie"

Element sa do funkcie dostane ako argument pri volaní

function mountApp(
    rootElement: HTMLElement,
    initialData: { commanders: Character[]; ambassadors: Character[] }
) {
    ReactDOM.createRoot(rootElement).render(
        <App initialData={initialData} />
    );
}

src/services/ui/mountApp.tsx

mountApp(
    document.getElementById('root') as HTMLElement,
    await getSortedCharacters()
);

src/main.tsx

Príklad 2

import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';

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

    return sortCharactersByRole(characters);
}

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

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

src/services/character/characterRepository.ts

Zmeňte URL, z ktorej sa načítava zoznam postáv.

Chceme rozšíriť funkciu o možnosť

načítavať zoznam postáv z inej URL

Nie je to možné bez zmeny jej kódu.

Funkcia tak nespĺňa Open-Closed Principle

Poďme ju zrefaktorovať.

  1. Zabezpečiť spĺňanie Single-Responsibility princípu
    • Funkcia už spĺňa
  2. Identifikovať všeobecné a špecifické časti úlohy
    • hardkódovaná URL je špecifická
  3. Oddeliť špecifické a všeobecné časti
    • vložíme URL do premennej
  4. Špecifické časti preniesť von
    • premenná z URL sa stane parametrom funkcie
  5. Prepojiť všeobecnú a špecifickú časť pomocou "konfigurácie"
    • pri volaní funkcie nastavíme URL

Naprogramujte to sami

import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';

export async function getSortedCharacters() {
    const characters = await loadCharacters('/api/list.json');

    return sortCharactersByRole(characters);
}

async function loadCharacters(url: string) {
    const result = await fetch(url);

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

src/services/character/characterRepository.ts

Hotovo

Naozaj?

import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';

export async function getSortedCharacters() {
    const characters = await loadCharacters('/api/list.json');

    return sortCharactersByRole(characters);
}

async function loadCharacters(url: string) {
    const result = await fetch(url);

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

src/services/character/characterRepository.ts

  • spĺňa teraz getSortedCharacters OCP?
    • nie
  • a má vôbec getFilteredCharacters niečo vedieť o nejakej URL?
    • nemá

Krátke a jednoduché riešenie

  • aby funkcia spĺňala OCP, tak nám stačí urobiť minimálne množstvo krokov
  • nemusíme hneď využiť všetky možnosti, ktoré refaktoringom získame
  • nemusíme vymýšľať novú architektúru aplikácie
  • je úplne dostačujúce, pokiaľ máme k dispozícii potenciál

V našom prípade stačí dať parametru defaultnú hodnotu.

import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';

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

    return sortCharactersByRole(characters);
}

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

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

src/services/character/characterRepository.ts

Hotovo

Dlhšie a komplikovanejšie riešenia

  • URL bude uložená v enume, konštante alebo zozname URL v nejakom konfiguračnom súbore
  • alebo: modul bude mať privátnu premennú držiacu URL a poskytne metódu na jej nastavenie zvonka
  • alebo: funkcia si URL vypýta z nejakého "registra" (pozor na vznik veľkého Service Locator-a)
// config/api.ts
export enum ApiUrl {
    LoadCharacters = '/api/list.json',
}

// src/services/character/characterRepository.ts
import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';
import ApiUrl from '@local/config/api';

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

    return sortCharactersByRole(characters);
}

async function loadCharacters() {
    const result = await fetch(ApiUrl.LoadCharacters);

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

src/services/character/characterRepository.ts

// src/services/character/characterRepository.ts
import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';

let loadCharactersUrl = '/api/list.json';

export function setLoadCharactersUrl(url) {
    loadCharactersUrl = url;
}

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

    return sortCharactersByRole(characters);
}

async function loadCharacters() {
    const result = await fetch(loadCharactersUrl);

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

src/services/character/characterRepository.ts

// src/services/api/urlRegistry

const registry: Record<string, string> = {};

export function setUrl(name, url) {
    registry[name] = url;
};

export function getUrl(name) {
    if (registry[name] === undefined) {
        throw new Error(`Unknown URL '${name}'`);    
    }
  
    return registry[name];
}

// // src/services/character/characterRepository.ts
import type { Character } from '@local/types';
import { sortCharactersByRole } from './sortCharactersByRole';
import { getUrl } from '@local/services/api/urlRegistry';

// ...

async function loadCharacters() {
    const result = await fetch(getUrl('loadCharactersUrl'));

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

src/services/character/characterRepository.ts

Aplikovaním SOLID princípov môžeme

odkryť nečakané abstrakcie

Aplikovaním SOLID princípov môžeme

odkryť nečakané abstrakcie

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

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

Aplikovaním SOLID princípov môžeme

odkryť nečakané abstrakcie

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

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

Aplikovaním SOLID princípov môžeme

odkryť nečakané abstrakcie

async function loadJson(url) {
    const result = await fetch(url);

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

Aplikovaním SOLID princípov môžeme

odkryť nečakané abstrakcie

async function loadJson<T>(url) {
    const result = await fetch(url);

    return (await result.json()) as Promise<T>;
}

Aplikovaním SOLID princípov môžeme

odkryť nečakané abstrakcie

async function loadJson<T>(url) {
    const result = await fetch(url);

    return (await result.json()) as Promise<T>;
}

async function loadCharacters(url = '/api/list.json') {
    return await loadJson<Character[]>(url);
}

Hotovo

Príklad 3

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

export function sortCharactersByRole(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,
    };
}

src/services/character/sortCharactersByRole.ts

Pod kľúč "aides" pridajte sortovanie podľa role "diplomatic-attache"

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

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

    characters.forEach((character: Character) => {
        if (character.role === 'commander') {
            commanders.push(character);
        } else if (character.role === 'ambassador') {
            ambassadors.push(character);
        } else if (character.role === 'diplomatic-attache') {
            aides.push(character);
        }
    });

    return {
        commanders,
        ambassadors,
        aides,
    };
}

src/services/character/sortCharactersByRole.ts

Síce sme funkciu rozšírili o možnosť triedenia podľa novej role,

ale iba tak, že sme zmenili jej kód.

Funkcia tak nespĺňa Open-Closed Principle.

Poďme ju zrefaktorovať.

  1. Zabezpečiť spĺňanie Single-Responsibility princípu
    • povedzme, že spĺňa
  2. Identifikovať všeobecné a špecifické časti úlohy
    • špecifické sú názvy rolí a sortovacie kľúče
  3. Oddeliť špecifické a všeobecné časti
    • špecifické časti dáme do samostatnej "mapovacej" premennej
import { Character } from '@local/types';

export function sortCharactersByRole(characters: Character[]) {
    const sortedCharacters: Record<string, Character[]> = {};

    const roleToSortKey: Record<RoleName, string> = {
        [RoleName.Commander]: 'commanders',
        [RoleName.Ambassador]: 'ambassadors',
        [RoleName.DiplomaticAttache]: 'aides',
    };

    characters.forEach((character: Character) => {
        const sortKey = roleToSortKey[character.role];

        if (!sortKey) {
            return;
        }

        if (sortedCharacters[sortKey] === undefined) {
            sortedCharacters[sortKey] = [];
        }

        sortedCharacters[sortKey].push(character);
    });

    return sortedCharacters;
}

src/services/character/sortCharactersByRole.ts

  1. Zabezpečiť spĺňanie Single-Responsibility princípu
    • Povedzme, že spĺňa
  2. Identifikovať všeobecné a špecifické časti úlohy
    • špecifické sú názvy rolí a sortovacie kľúče
  3. Oddeliť špecifické a všeobecné časti
    • špecifické časti dáme do samostatnej "mapovacej" premennej
  4. Špecifické časti preniesť von
    • premennú vytiahneme von z funkcie
import { Character } from '@local/types';

const roleToSortKey: Record<RoleName, string> = {
    [RoleName.Commander]: 'commanders',
    [RoleName.Ambassador]: 'ambassadors',
    [RoleName.DiplomaticAttache]: 'aides',
};

export function sortCharactersByRole(characters: Character[]) {
    const sortedCharacters: Record<string, Character[]> = {};

    characters.forEach((character: Character) => {
        const sortKey = roleToSortKey[character.role];

        if (!sortKey) {
            return;
        }

        if (sortedCharacters[sortKey] === undefined) {
            sortedCharacters[sortKey] = [];
        }

        sortedCharacters[sortKey].push(character);
    });

    return sortedCharacters;
}

src/services/character/sortCharactersByRole.ts

  1. Zabezpečiť spĺňanie Single-Responsibility princípu
    • Povedzme, že spĺňa
  2. Identifikovať všeobecné a špecifické časti úlohy
    • špecifické sú názvy rolí a sortovacie kľúče
  3. Oddeliť špecifické a všeobecné časti
    • špecifické časti dáme do samostatnej "mapovacej" premennej
  4. Špecifické časti preniesť von
    • premennú vytiahneme von z funkcie
  5. Prepojiť všeobecnú a špecifickú časť pomocou "konfigurácie"
    • pri volaní funkcie nastavíme parameter so špecifikáciou mapovania
    • alebo: poskytneme setter pre premennú
    • alebo: mapovacia premenná pôjde do konfiguračného súboru
    • ...
import { Character } from '@local/types';

const defaultRoleToSortKey: Record<RoleName, string> = {
    [RoleName.Commander]: 'commanders',
    [RoleName.Ambassador]: 'ambassadors',
    [RoleName.DiplomaticAttache]: 'aides',
};

export function sortCharactersByRole(
    characters: Character[],
    roleToSortKey = defaultRoleToSortKey
) {
    const sortedCharacters: Record<string, Character[]> = {};

    characters.forEach((character: Character) => {
        const sortKey = roleToSortKey[character.role];

        if (!sortKey) {
            return;
        }

        if (sortedCharacters[sortKey] === undefined) {
            sortedCharacters[sortKey] = [];
        }

        sortedCharacters[sortKey].push(character);
    });

    return sortedCharacters;
}

src/services/character/sortCharactersByRole.ts

Hotovo

Naozaj?

Koľko má teraz funkcia zodpovedností?

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

const defaultRoleToSortKey: Record<RoleName, string> = {
    [RoleName.Commander]: 'commanders',
    [RoleName.Ambassador]: 'ambassadors',
    [RoleName.DiplomaticAttache]: 'aides',
};

export function sortCharactersByRole(
    characters: Character[],
    roleToSortKey = defaultRoleToSortKey
) {
    const sortedCharacters: Record<string, Character[]> = {};

    characters.forEach((character: Character) => {
        const sortKey = roleToSortKey[character.role];

        if (!sortKey) {
            return;
        }

        if (sortedCharacters[sortKey] === undefined) {
            sortedCharacters[sortKey] = [];
        }

        sortedCharacters[sortKey].push(character);
    });

    return sortedCharacters;
}

src/services/character/sortCharactersByRole.ts

  • získavanie sortovacieho kľúča
  • pridávanie postáv do správneho poľa
  • triedenie postáv

Refaktorujte, aby sme spĺňali Single Responsibility Principle

  • extrahovať špecifické úlohy do pomocných funkcií
  • identifikovať skrytých kolaborantov
  • preniesť kód skrytých kolaborantov do samostatných modulov
const defaultRoleToSortKey: Record<RoleName, string> = { ... };

export function getKeyByRole(role: RoleName) {
    return sortKeyByRole[role];
}

export function addToSortedCharacters(
    character: Character,
    sortedCharacters: Record<string, Character[]>,
    sortKey: string
) {
    if (sortedCharacters[sortKey]) {
        sortedCharacters[sortKey].push(character);
    } else {
        sortedCharacters[sortKey] = [character];
    }
}

export function sortCharactersByRole(
    characters: Character[],
    roleToSortKey = defaultRoleToSortKey
) {
    const sortedCharacters: Record<string, Character[]> = {};

    characters.forEach((character: Character) => {
        const sortKey = getKeyByRole(character.role);

        if (sortKey) {
            addToSortedCharacters(character, sortedCharacters, sortKey);    
        }
    });

    return sortedCharacters;
}

src/services/character/sortCharactersByRole.ts

Hotovo

Opakovanie

Jednotka kódu by mala byť otvorená pre rozširovanie a zároveň uzatvorená pre zmeny

Mali by sme byť schopní rozšíriť správanie jednotky aj bez potreby modifikácie jej kódu

Výhody dodržiavania

Open-Closed Principle

  • možnosť zmeniť správanie jednotky kódu aj bez jej úpravy
  • bezpečnejšie úpravy (lebo nemeníme kód)

Symptómy porušenia princípu

  • v kóde sú podmienky pre určenie stratégie (typicky napr. switch)
  • podobné podmienky sa opakujú na viacerých miestach v kóde
  • kód obsahuje hardkódovaný názov súboru, emailovú adresu alebo inú skalárnu hodnotu
  • trieda obsahuje natvrdo nakódované názvy iných tried (v poli, podmienkach, stringoch)
  • vo vnútri triedy sa vytvárajú objekty pomocou new

Ako refaktorovať

  • zabezpečiť spĺňanie Single Responsibility Principle
  • identifikovať všeobecné a špecifické časti úlohy
  • oddeliť špecifické a všeobecné úlohy
  • špecifické časti preniesť von
  • prepojiť všeobecnú a špecifickú časť pomocou "konfigurácie"

Ďakujem za pozornosť