SOLID v JavaScripte: Single Responsibility Principle

27.03.2023

SOLID princípy nám hovoria, ako by sme mali písať náš kód, aby bol ľahko rozširovateľný, čitateľný a udržiavateľný. Hoci tieto princípy boli definované pre podmienky OOP jazykov, tak v skutočnosti stoja na hlbších základoch a sú tak aplikovateľné aj v jazykoch, ktoré nie sú z rodiny OOP. Pokúsim sa tu v piatich článkoch ukázať, ako dodržiavať SOLID princípy v React aplikácii písanej v TypeScripte bez použitia tried.

Single Responsibility Principle

V každom článku sa budem venovať jednému zo SOLID princípov. Prvým na rade je Single Responsibility Principle. Tento princíp má skratku SRP, do slovenčiny sa prekladá ako "Princíp jedinej zodpovednosti" a jeho definícia znie nasledovne:

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

Pristavme sa chvíľu pri tom, že kým názov hovorí o zodpovednostiach, tak definícia o dôvodoch pre zmenu. Prečo zrazu ten rozdiel? Porozmýšľajte.

Keď si skúsite vymenovať dôvody, pre ktoré by ste mohli chcieť upraviť kód nejakej triedy, tak zistíte, že to sú všetko veci, ktoré daná trieda rieši. A keď ich rieši, tak je za ne zodpovedná. Sú to jej zodpovednosti. Takže vlastne medzi zodpovednosťou a dôvodom pre zmenu nie je rozdiel.

Trieda by teda mala mať jednu jedinú zodpovenosť a tým pádom jeden jediný dôvod pre svoju zmenu.

Ako som ale spomínal. Princípy sú všobecné a neplatia len pre OOP jazyky a pre triedy. Ako by teda mala znieť definícia SRP pre JavaScript?

Jednotka kódu by mala mať iba jeden dôvod pre svoju zmenu.

Pod pojmom "jednotka kódu" si môžete predstaviť čokoľvek z množiny: trieda, modul (súbor), objekt a funkcia. Princíp sa totiž aplikuje na všetko.

Dôležité je uvedomiť si, že pri pomenovávaní tej jedinej zodpovednosti nejakej jednotky by ste nemali použiť spojku "a"

Výhody dodržiavania Single Responsibility Principle

Pokiaľ píšeme kód tak, že dodržiavame SRP, tak dosiahneme:

  • jednoduchšie úpravy miernym prepisom alebo výmenou jednotky
  • bezpečnejšie neskoršie úpravy, lebo v kóde nie sú nesúvisiace veci
  • jednoduchšie pochopenie toho, čo jednotka robí
  • ľahkú testovateľnosť jednotky vyplývajúcu zo skôr vymenovaných bodov

Symptómy porušenia princípu

Ak chcete zistiť, či jednotka, na ktorú sa práve pozeráte, spĺňa tento princíp alebo nie, tak si môžete skúsiť vymenovať dôvody zmien, pre ktoré by ste ju museli niekedy prepísať. Našli ste iba jeden? Gratulujem, jednotka pravdepodobne spĺňa SRP. Problém je trochu v tom, že ak zodpovednosť (dôvod pre zmenu) pomenujeme všeobecnejšie, tak sa v nej ľahko schovajú zodpovenosti dve (alebo aj viac). Bohužiaľ, na rozpoznanie tohto rizika pomôže len prax.

Našťastie existuje niekoľko vzorov, ktoré je pri porušení princípu zvyčajne dobre vidieť. Ak ich v kóde vami skúmanej jednotky vidíte, tak je vysoká pravdepodobnosť, že SRP porušuje. Nie je to zaručené, ale šanca je vysoká.

Nuž a aké tieto vzory sú?

Pre triedy a objekty:

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

Pre moduly:

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

Pre funkcie:

  • funkcia má priveľa riadkov (50 je veľa, pri 30 by nám mala blikať kontrolka)
  • funkcia obsahuje komentáre, ktoré pomenovávajú jej časti
  • je možné funkciu rozdeliť na viacero menších podľa logických celkov
  • funkcia má priveľa úrovní vnorenia vytvorených pomocou podmienok a cyklov (2 úrovne vnorenia sú rozumné maximum)

Ako refaktorovať

Keď už viete, že nejaký kód porušuje SRP, tak refaktoring spočíva z troch krokov:

  1. extrahujete špecifické úlohy do pomocných funkcií (prípadne metód)
  2. identifikujete skrytých kolaborantov
  3. prenesiete skrytých kolaborantov do samostatných modulov

Skrytý kolaborant je funkcia, ktorá dokáže existovať sama o sebe a v skutočnosti nemá žiadne silné previazanie na logiku skúmanej jednotky.

Ja pri uvažovaní o tom, či je nejaká pomocná funkcia skrytým kolaborantom, ktorého treba preniesť, alebo len pomocnou funkciou, ktorá má ostať v aktuálnom module, zvažujem to, či mi dáva zmysel, aby funkciu volala aj nejaká iná časť aplikácie. Ak je na to teoretická šanca, tak ju presúvam do samostatného (alebo iného už existujúceho) modulu.

Príklad

Dobre sa pozrite na tento kód. Je to kód, ktorý sa podobá tomu, čo často vidieť v úvodných skriptoch React aplikácií, kde sa deje ich inicializácia. Ja som príklad značne zjednodušil a máme tu len načítanie úvodných dát a namontovanie React aplikácie. Kvôli jednoduchosti som neriešil žiadne skeletony, routovanie, preklady, lazy loading a React hlúpo čaká na to, kým dobehne ajaxový request. Ale pre účely učenia sa Single Responsibility Principle je to dostatočné.

// súbor src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { Character, RoleName } 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 === RoleName.Commander) {
        commanders.push(character);
    } else if (character.role === RoleName.Ambassador) {
        ambassadors.push(character);
    }
});

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

Zamyslite sa nad tým, koľko zodpovedností má tento kus kódu? Pri akých dôvodoch by ste do neho museli siahnuť a niečo zmeniť?

Ja som narátal dve hlavné zodpovednosti:

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

Keďže sú zodpovednosti dve, tak už vieme, že kód nespĺňa SRP a mali by sme ho refaktorovať. Prvým krokom je extrahovanie špecifických úloh do pomocných funkcií.

Krok 1: Extrahovanie špecifických úloh do pomocných funkcií

V programovaní je mimoriadne dôležité používať dobré pomenovania. Funkcia by mala byť pomenovávaná tak, aby nám už z názvu bolo jasné, čo je jej úlohou. Pri dodržiavaní SRP je tento názov zároveň veľmi často aj pomenovaním jej hlavnej zodpovednosti. Ja ich preto nazvem: getInitialData a mountApp.

// súbor src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { Character, RoleName } from './types';

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,
    };
}

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>
    );
}

mountApp(await getInitialData());

Trochu sa nám síce zvýšil počet riadkov v kóde, ale to nevadí. Počet riadkov nemá absolútne žiaden vzťah ku kvalite kódu. Našich riadkov je viac, ale čitateľnosť je vyššia.

Krok 2: Identifikovať skrytých kolaborantov

Máme teda kód v module rozdelený do dvoch funkcií, z ktorých každá je zodpovedná za niečo iné. Teraz sa musíme rozhodnúť, ktoré z týchto funkcií sú prirodzené pre tento modul a je vhodné, aby v ňom ostali. A na druhej strane nájsť také, ktoré nie sú špecifické pre modul a sú teoreticky všeobecne použiteľné.

Najskôr si však musíme povedať, čo je primárnou zodpovednosťou modulu (súboru src/index.tsx). Podľa mňa to je spustenie aplikácie.

Mne sa potom ako jednoznačný kandidát na skrytého kolaboranta javí funkcia getInitialData. Zdá sa, že ona poskytuje dáta z nejakého endpointu a kľudne by si rovnaké dáta mohla vypýtať aj iná časť našej aplikácie. Presunul by som ju preto do samostatného modulu.

Trochu ťažšie je to s funkciou mountApp. Tú na prvý pohľad už zrejme v inej časti aplikácie nepoužijeme. Ale naozaj je to tak? Čo keby sme ju chceli otestovať automatizovanými testami? Navyše si viem predstaviť, že pre spustenie aplikácie (primárna zodpovednost modulu) nemusíme zavolať práve túto mountApp. Možno budeme chcieť iný spôsob montovania pre produkčné alebo nejaké špeciálne prostredie. Takže osamostatníme aj mountApp.

Krok 3: Presun skrytých kolaborantov do samostatných modulov

Funkcia getInitialData rieši získavanie dát o nejakých postavách. Presuniem ju preto do súboru src/services/character/characterRepository.ts.

// súbor src/services/character/characterRepository.ts
import { Character, RoleName } from './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,
    };
}

Funkcia mountApp je služba používateľského rozhrania. Tú dám do súboru src/services/ui/mountApp.tsx.

// súbor src/services/ui/mountApp.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Character } from '@local/services/character/characterRepository';
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;

Po týchto presunoch vyzerá src/index.tsx takto:

// súbor src/index.tsx
import { getInitialData } from '@local/services/character/characterRepository';
import mountApp from '@local/services/ui/mountApp';

mountApp(await getInitialData());

Oveľa čitateľnejšie. Už z názvov funkcií je jasné, čo sa kde rieši.

Hoci sme si prešli všetkými troma bodmi, ktoré nám hovoria, ako refaktorovať jednotku, ktorá nespĺňa SRP, tak ešte nemáme hotové všetko. Dosiahli sme, že modul index.tsx už spĺňa Single Responsibility Principle. Ale aby sme mohli povedať, že s refaktoringom sme skončili, tak by sme sa mali pozrieť aj na všetky novo vytvorené funkcie. Spĺňajú oni tiež tento princíp?

mountApp je z tohto pohľadu celkom v poriadku. Sú tam síce nejaké problémy, ale tie už patria pod Open-Closed Principle, o ktorom si povieme nabudúce.

Horšie to je s funkciou getInitialData. Skúste pomenovať jej zodpovednosti. Pri akých dôvodoch by ste potrebovali siahnuť na jej kóde a zmeniť ho?

Ja vidím dva dôvody:

  • získavanie dát z backendu
  • filtrovanie dát

Čo s tým budeme robiť? Presne to isté, čo aj s modulom index.tsx a aplikujeme rovnaký algoritmus. Extrahujeme kód do pomocných funkcií. Potom sa zamyslíme, či niektoré z funkcií sú skrytými kolaborantami a pokiaľ áno, tak ich presunieme do iných modulov.

Urobme prvý krok:

// súbor src/services/character/characterRepository.ts
import { Character, RoleName } from './types';

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[]>;
}

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 };
}

Druhým krokom je premýšľanie o tom, či niektorá z funkcií je skrytým kolaborantom. loadCharacters je pomerne úzko spätá s primárnou zodpovednosťou modulu characterRepository, ktorým je poskytovanie prístupu k zoznamu postáv. Tým pádom si myslím, že funkcia je len pomocnou pre tento modul a ako taká môže ostať jeho súčasťou.

Avšak, silné podozrenie na skrytého kolaboranta mám pri funkcii filterCharactersByRole. Ide len o filtrovanie zoznamu postáv, ktoré dostane v argumente. Nie je žiaden špeciálny dôvod na to, aby táto funkcia bola silno prepojená na modul poskytujúci prístup k zoznamu postáv. Vytiahneme ju teda von. Ja som zvolil súbor src/services/character/filterCharacters.ts. Výsledok by mohol vyzerať takto:

// súbor src/services/character/characterRepository.ts
import { Character } from './types';
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[]>;
}
// súbor src/services/character/filterCharacters.ts
import { Character, RoleName } from './types';

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 };
}

V tejto chvíli by sme refaktoring pre potreby spĺňania SRP mohli vyhlásiť za skončený. Ostala nám však jedna drobnosť, ktorá nie je úplne v poriadku. Názov funkcie getInitialData dáva zmysel iba v kontexte modulu index.tsx. Avšak my sme z nej pred chvíľkou zistili, že to je všeobecne použiteľná funkcia. Pokiaľ ju bude volať niečo iné v našej aplikácii, tak názov getInitialData bude celkom mätúci. Vhodnejšie pomenovanie by mohlo byť getFilteredCharacters.

Urobme patričné úpravy a už sme naozaj skončili.

// súbor src/services/character/characterRepository.ts
import { Character } from './types';
import { filterCharactersByRole } from './filterCharacters';

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[]>;
}
// súbor src/index.tsx
import { getFilteredCharacters } from '@local/services/character/characterRepository';
import mountApp from '@local/services/ui/mountApp';

mountApp(await getFilteredCharacters());

Zhrnutie

Ukázal som vám, ako v JavaScripte a TypeScripte rozpoznáte porušenie Single Responsibility Principle a akým postupom viete kód upraviť, aby tento princíp spĺňal.

Možno vás niektorých napadlo, že kým na začiatku sme mali jeden súbor s 25 riadkami kódu, tak na konci už máme súbory štyri a dokopy 52 riadkov kódu. Opticky to teda vyzerá tak, že sme zvýšili komplexnosť a tým stratili prehľadnosť. Takže kde je ten benefit?

Hoci nám vzniklo viac súborov a funkcií, tak všetky tieto sú krátke.

Nové funkcie a aj moduly vieme použiť v iných častiach našej aplikácie a možno ich aj vytiahnuť do inej aplikácie.

Funkcie aj súbory sú dobre pomenované a pokiaľ do nich nepotrebujeme siahať, tak nemusíme študovať ich kód. Ak by sme potrebovali urobiť zmenu, tak podľa názvu vieme nájsť správny modul a funkciu, kde túto zmenu bude potrebné urobiť.

Potrebnú zmenu urobíme jednoduchšie, lebo v kóde funkcie sa nám nepletú pod ruky nesúvisiace veci.

A v neposlednom rade vieme funkcie otestovať samostatne a izolovane.