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)
- 235