Milan Herda, 05 / 2023
SOLID princípy v JavaScripte
Skratka predstavujúca 5 základných princípov dobrého softvérového návrhu
Nie je iba pre OOP
Princípy sú všeobecne použiteľné
Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou svojho interfacu
Objekt musí byť náhradou svojho interfacu
Implementácia musí byť náhradou svojho typového predpisu
Byť dobrou náhradou svojej základnej triedy/interfacu
znamená:
Cieľom LSP je kód, kde môžeme
vymeniť implementáciu rodiča
za implementáciu potomka
a kód bude ďalej fungovať.
Pozrieme sa preto na menej triviálne situácie
Podľa individuálnej situácie
Často je problém v nesprávne definovanej základnej triede/interface
// entity/user.ts
export interface User {
getId: () => number;
getName: () => string;
getType: () => UserType;
getFavouriteTvShow: () => string;
getAdminRights: () => AdminRight[];
}
const users: User[] = [
{
getId: () => 11,
getName: () => 'Ferko Mrkvička',
getType: () => UserType.User,
getFavouriteTvShow: () => 'Babylon 5',
//getAdminRights: () => ???,
},
// ...
];
const admins: User[] = [
{
getId: () => 101,
getName: () => 'Neo',
getType: () => UserType.Admin,
//getFavouriteTvShow: () => ???,
getAdminRights: () => [
AdminRight.BanUser,
AdminRight.EditUser,
AdminRight.ViewUser
],
},
// ...
];
Doplňte kód pre chýbajúce metódy
Stiahnite si zdrojáky
Problém je, že objekty majú nastaveného nesprávneho rodiča
Prečo má interface User metódy getFavouriteShow a getAdminRights, keď ich nevedia implementovať všetci potomkovia?
export interface BaseUser {
getId: () => number;
getName: () => string;
getType: () => UserType;
}
export interface User extends BaseUser {
getFavouriteTvShow: () => string;
getType: () => UserType.User;
}
export interface AdminUser extends BaseUser {
getAdminRights: () => AdminRight[];
getType: () => UserType.Admin;
}
src/entity/user.ts
import { UserType, AdminRight, AdminUser } from '@local/entity/user';
export function getAdmins() {
const admins: AdminUser[] = [
{
getId: () => 101,
getName: () => 'Neo',
getType: () => UserType.Admin,
getAdminRights: () => [
AdminRight.BanUser,
AdminRight.EditUser,
AdminRight.ViewUser
],
},
{
getId: () => 102,
getName: () => 'Mr. Anderson',
getType: () => UserType.Admin,
getAdminRights: () => [AdminRight.ViewUser],
},
];
return admins;
}
src/services/users/adminRepository.ts
import { AdminUser } from '@local/entity/user';
function ListOfAdminUsers({ users }: { users: AdminUser[] }) {
// ...
}
function AdminRow({ user }: { user: AdminUser }) {
// ...
}
src/components/ListOfAdminUsers.tsx
export interface Validator {
getRules: () => any;
validate: (input: any) => boolean;
}
const StrictValidator: Validator = {
getRules() {
return [
ValidationRule.Required,
ValidationRule.NotEmpty,
ValidationRule.IsNumeric
];
},
validate(input: any) {
// ...
},
};
const BenevolentValidator: Validator = {
getRules() {
return null;
},
validate(input: any) {
// ...
},
};
function ListOfValidation({ validators }: { validators: Validator[] }) {
return (
<>
<h2 className="title is-2">Validation Rules</h2>
<table className="table is-striped">
<thead>
<th>Validator</th>
<th>Rules</th>
</thead>
<tbody>
{validators.map((validator) => {
return (
<tr key={`${validator.getName()}`}>
<td>{validator.getName()}</td>
<td>{validator.getRules().join(', ')}</td>
</tr>
);
})}
</tbody>
</table>
</>
);
}
LSP je síce formálne dodržané, ale...
Skúste kód upraviť tak, aby rodičovský typ presnejšie definoval návratový typ pre getRules
export interface Validator {
getRules: () => ValidationRule[];
validate: (input: any) => boolean;
}
const StrictValidator: Validator = {
getRules() {
return [
ValidationRule.Required,
ValidationRule.NotEmpty,
ValidationRule.IsNumeric
];
},
validate(input: any) {
// ...
},
};
const BenevolentValidator: Validator = {
getRules() {
return [];
},
validate(input: any) {
// ...
},
};
// types.ts
import { UserBase } from '@local/entity/user';
export type AdminRightsFormatter = (user: UserBase) => string;
// index.ts
import { UserType } from '@local/entity/user';
import { AdminRightsFormatter } from './types';
export const rightsFormatter: AdminRightsFormatter = function (user) {
if (user.getType() !== UserType.Admin) {
throw new Error(`User ${user.getId()} is not an admin`);
}
return user.getAdminRights().join(', ');
};
src/services/users/adminRights/*
LSP (a dedičnosť) vraví, že namiesto inštancie rodičovského typu viem použiť inštanciu potomka a kód pobeží ďalej.
export const rightsFormatter: AdminRightsFormatter = function (user) {
if (user.getType() !== UserType.Admin) {
throw new Error(`User ${user.getId()} is not an admin`);
}
return user.getAdminRights().join(', ');
};
Vyhodenie výnimky toto pravidlo porušuje, lebo rodičovský typ o tom nehovorí.
Nie je to ani súčasťou neformálneho kontraktu (napr. v komentári k typu)
Odstráňte vyhadzovanie výnimky
import { UserType } from '@local/entity/user';
import { AdminRightsFormatter } from './types';
export const rightsFormatter: AdminRightsFormatter = function (user) {
if (user.getType() !== UserType.Admin) {
return '';
}
return user.getAdminRights().join(', ');
};
Je v poriadku, keď metóda dostane argument typu, s ktorým neráta?
Ako vieme, že sme pre tento typ zadefinovali správne správanie?
Čo ak by mohlo byť nesprávnych typov viac?
A prečo nám rodič prikazuje robiť s typom, o ktorom by mohol vedieť, že nie je správny?
Opäť máme problém v rodičovi!
Rodičovský typ by nemal definovať príliš široké požiadavky na potomkov.
Mal by potomkom ponúknuť jasné hranice, ale zároveň implementačnú voľnosť.
Nemal by nám prikazovať pracovať s typom, o ktorom môže predpokladať, že nie je správny.
// types.ts
import { UserBase } from '@local/entity/user';
export type AdminRightsFormatter = (user: UserBase) => string;
src/services/users/adminRights/types.ts
// types.ts
import { AdminUser } from '@local/entity/user';
export type AdminRightsFormatter = (user: AdminUser) => string;
// index.ts
import { AdminRightsFormatter } from './types';
export const rightsFormatter: AdminRightsFormatter = function (user) {
return user.getAdminRights().join(', ');
};
src/services/users/adminRights/*
Čo by sme robili v prípade, keby AdminUser neexistoval a mali by sme iba typ User so začiatku školenia?
Vytvorili by sme ho
export type GetFansFunc = () => User[];
export const getFansOfVariousShows: GetFansFunc = function () {
return [
createFan(101, 'Susan', 'Star Trek'),
createFan(102, 'Monica', 'Star Wars'),
createFan(103, 'Peter', 'StarGate - SG1'),
createFan(104, 'John', 'Battlestar Galactica'),
];
};
function createFan(id: number, name: string, tvShow: string) {
let favouriteTvShow = tvShow;
return {
getId: () => id,
getName: () => name,
getType: () => UserType.User,
getFavouriteTvShow: () => favouriteTvShow,
setFavouriteTvShow: (show: string) => {
favouriteTvShow = show;
},
};
}
src/services/fans/fansRepository.ts
function watchFewEpisodesOfB5(fan: User) {
const tvShow = new TvShow('Babylon 5');
tvShow.addFan(fan);
}
const fans = getFansOfVariousShows();
watchFewEpisodesOfB5(fans[0]);
src/components/ListOfFans.tsx
class TvShow {
name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return this.name;
}
addFan(user: User) {
user.setFavouriteTvShow(this.name);
}
}
export default TvShow;
src/services/tvShows/index.ts
class TvShow {
name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return this.name;
}
addFan(user: User) {
// @ts-ignore
user.setFavouriteTvShow(this.name);
}
}
export default TvShow;
src/services/tvShows/index.ts
Programovať by sme mali len voči deklarovaným rozhraniam
Ak nám rozhranie nevyhovuje, tak ho vymeníme
Typ User pre metódu addFan nie je dostatočný, vymeníme ho
export interface Fan extends User {
setFavouriteTvShow: (show: string) => void;
}
export type GetFansFunc = () => Fan[];
export const getFansOfVariousShows: GetFansFunc = function () {
return [
createFan(101, 'Susan', 'Star Trek'),
createFan(102, 'Monica', 'Star Wars'),
createFan(103, 'Peter', 'StarGate - SG1'),
createFan(104, 'John', 'Battlestar Galactica'),
];
};
function createFan(id: number, name: string, tvShow: string) {
let favouriteTvShow = tvShow;
return {
getId: () => id,
getName: () => name,
getType: () => UserType.User,
getFavouriteTvShow: () => favouriteTvShow,
setFavouriteTvShow: (show: string) => {
favouriteTvShow = show;
},
};
}
src/services/fans/fansRepository.ts
function watchFewEpisodesOfB5(fan: Fan) {
const tvShow = new TvShow('Babylon 5');
tvShow.addFan(fan);
}
const fans = getFansOfVariousShows();
watchFewEpisodesOfB5(fans[0]);
src/components/ListOfFans.tsx
class TvShow {
name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return this.name;
}
addFan(user: Fan) {
user.setFavouriteTvShow(this.name);
}
}
export default TvShow;
src/services/tvShows/index.ts
Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou svojho interfacu
Objekt musí byť náhradou svojho interfacu
Implementácia musí byť náhradou svojho typového predpisu
Byť dobrou náhradou svojej základnej triedy/interfacu
znamená: