
Rules engine
Bronislav Klučka, 03. 8. 2025 21:01
Návrhové vzory jsou jako nástroje ve vaší dílně. Dokáží výrazně zjednodušit jak psaní kódu, tak kód samotný. Dnes začneme ukázkami patternů specificky se vzorem s názvem Rules Engine.
Rules engine vám umožní zlepšit jak separation of concerns, tak high cohesion and low coupling a výrazně tím snížit míru komplexity kódu.
Začněme 2 ukázkami.
Příklad 1: spam filter
Úkolem je přidat do aplikace kód, který bude hodnotit, zda email je spam, nebo ne na základě následujících pravidel.
- Začneme na skóre 0
- Pokud je v předmětu emailu slovo, které máme ve spam slovníku, zvýšíme skóre o 2 body.
- Pokud je v textu emailu slovo, které máme ve spam slovníku, zvýšíme skóre o 1 bod.
- Pokud je v textu emailu url, zvýšíme skóre o 1 bod.
- Pokud je v textu emailu url a stáří domény je méně, než půl roku, zvýšíme skóre o 1 bod.
- Pokud je v odesilatel emailu v našem seznamu kontaktů, snížíme skóre o 3 body.
- Pokud je výsledné skóre větší než 0, email je spam.
Pseudo-implementace
interface Email {
from: string;
subject: string;
text: string;
}
function isSpam(email: Email): boolean {
let score = 0;
if (containsSpamWord(email.subject)) {
score += 2;
}
if (containsSpamWord(email.text)) {
score += 1;
}
if (containsUrl(email.text)) {
score += 1;
}
const allUrl = extractAllUrl(email.text);
const found = allUrl.find(url => getDomainAgeInMonths(url) < 6);
if (found) {
score += 1;
}
if (emailInContacts(email.from)) {
score -= 3;
}
return score > 0;
}
function containsSpamWord(text: string): boolean { ... }
function containsUrl(text: string): boolean { ... }
function extractAllUrl(text: string): string[] { ... }
function getDomainAgeInMonths(url: string): number { ... }
function emailInContacts(email: string): boolean { ... }
Výše uvedený kód bude funkční, ale zkuste si představit, jak se může dál vyvíjet: přidáte kontrolu SPF a DKIM, kontrolu příloh, reverse DNS lookup, kontrolu domain blocklistu, sender reputation....
Časem se kód stane nepřehlednou změtí různých podmínek a vyhodnocování. Vyhodnocují se různé podmínky, různých částí emailů, mezi tím se manipuluje se skóre...
Kód začne být tak velký, že bude mít vlastní gravitační pole a budou ho obíhat 4 měsíce...
Příklad 2: počítání ceny
Představte si e-commerce aplikaci, ve které musíte spočítat cenu produktu pro uživatele na základě následujících pravidel.
- Pokud je uživatel přihlášen (má účet), poskytneme slevu 5 %.
- Každou 10. objednávku zlevníme o 4 %.
- Uživateli v rámci EU zlevníme o 3 %.
- Pokud zadal uživatel slevový kód, zlevníme o 5 %
- Pokud uživatel koupil 3 a více kusů, zlevníme o 6 %
- Každou sobotu zlevňujeme o 7 %
Pseudo-implementace
interface User {
isLoggedIn: boolean;
address: UserAddress;
}
interface UserAddress {
country: string;
}
interface UserOrderHistory {
getOrdersCount: number;
}
interface OrderItem {
price: number;
count: number;
}
function isCountryInEU(country: string): boolean { ... }
function isValidCode(code: string): boolean { ... }
function countPrice(item: OrderItem, user: User, orderHistory: UserOrderHistory, code: string): number {
let newPrice = item.price;
if (user.isLoggedIn) {
newPrice *= 0.95;
}
if ((orderHistory.getOrdersCount > 0) && (orderHistory.getOrdersCount % 10 === 0)) {
newPrice *= 0.96;
}
if (isCountryInEU(user.address.country)) {
newPrice *= 0.97;
}
if (isValidCode(code)) {
newPrice *= 0.97;
}
if (item.count >= 3) {
newPrice *= 0.95;
}
if ((new Date()).getDay() === 6) {
newPrice *= 0.93;
}
return newPrice;
}
Výše uvedený je ještě horší, než v 1. příkladu. Nejenže má podobný problém, může růst bez omezení do naprosté změti podmínek, ale má ještě jednu horší vlastnost.
Kód pro výpočet ceny z nějakého důvodů zná strukturu uživatele, adresy, má přístup k historii objednávek... Toto je přesně ten přiklad, kdy vznikne mega funkce/třída, která se rozbije ať sáhnete kamkoliv, protože se odkazuje na detaily velkého množství dalších objektů, se kterými vlastně nemá nic společného. Představte si, že se navíc může jednat o uživatele vašich partnerů (B2B) a budete, ještě na základě partnera, kterému uživatel patří, upravit slevu. A nejednou daná funkcionalita zná detailní informace o dalším typu objektu (Partner).
Refaktoring do objektu a rozdělení velké funkce do sekvence malých funkcí danů problém vůbec neodstraní.
Rules engine.
Rules engine je pattern, který umožňuje oddělení business pravidel a jejich definice od jejich provedení při běhu programu.
Rules engine je definován 3 parametry:
- Vstup - data, která se mají vyhodnotit
- Pravidla - sekvence pravidel, které se má provést
- Výstup - výsledek vyhodnocení
Podstata tkví v tom, že pravidla nejsou součástí implementace rules engine, ale rules engine se z nich složí.
Příklad 1: spam filter
/************************************
* spamEngine.mts
*/
interface SpamRule {
/**
* function accepting email and returning score modifier
* @param email
* @returns
*/
processEmail: (email: Email) => number;
}
class SpamEngine {
protected rules: SpamRule[];
addRules(rules: SpamRule[]) {
this.rules.push(...rules);
}
execute(email: Email): boolean {
const score = this.rules.reduce((acc, rule) => {
return acc + rule.processEmail(email);
}, 0);
return score > 0;
}
}
/************************************
* email.mts
*/
function containsSpamWord(text: string): boolean { ... }
interface Email {
from: string;
subject: string;
text: string;
}
class SpamText implements SpamRule {
processEmail(email: Email): number {
if (containsSpamWord(email.text)) {
return 1;
}
return 0;
}
}
class SpamSubject implements SpamRule {
processEmail(email: Email): number {
if (containsSpamWord(email.subject)) {
return 2;
}
return 0;
}
}
/************************************
* textInfo.mts
*/
function extractAllUrl(text: string): string[] { ... }
function getDomainAgeInMonths(url: string): number { ... }
class SpamTextUrl implements SpamRule {
processEmail(email: Email): number {
if (extractAllUrl(email.text).length > 0) {
return 1;
}
return 0;
}
}
class SpamUrlDomainAge implements SpamRule {
processEmail(email: Email): number {
const allUrl = extractAllUrl(email.text);
const found = allUrl.find(url => getDomainAgeInMonths(url) < 6);
if (found) {
return 1;
}
return 0;
}
}
/************************************
* contact.mts
*/
function emailInContacts(email: string): boolean { ... }
class SpamContact implements SpamRule {
processEmail(email: Email): number {
if (emailInContacts(email.from)) {
return -3;
}
return 0;
}
}
/************************************
* programm.mts
*/
const spamEngine = new SpamEngine();
spamEngine.addRules([
new SpamSubject(),
new SpamText(),
new SpamTextUrl(),
new SpamUrlDomainAge(),
new SpamContact(),
]);
console.log(spamEngine.execute({
from: 'john.doe@company.com',
subject: 'text email',
text: 'this is spam',
}));
Příklad 2: počítání ceny
/************************************
* orderModule.mts
*/
export interface OrderItem {
price: number;
count: number;
}
/************************************
* priceEngine.mts
*/
export interface PriceRule {
/**
* function accepting current price of item and item and returns new current price
*/
computePrice: (currentPrice: number, item: OrderItem) => number;
}
class PriceEngine {
protected rules: PriceRule[];
addRules(rules: PriceRule[]) {
this.rules.push(...rules);
}
execute(item: OrderItem): number {
const newPrice = this.rules.reduce((acc, rule) => {
return rule.computePrice(acc, item);
}, item.price);
return newPrice;
}
}
/**********************
* userModule.mts
*/
interface User {
isLoggedIn: boolean;
address: UserAddress;
}
interface UserAddress {
country: string;
}
function isCountryInEU(country: string): boolean { ... }
class UserPriceRule implements PriceRule {
constructor(protected user: User) {};
computePrice(currentPrice: number, item: OrderItem): number {
if (this.user.isLoggedIn) {
return currentPrice * 0.95;
}
return currentPrice;
}
}
class UserAddressPriceRule implements PriceRule {
constructor(protected user: User) {};
computePrice(currentPrice: number, item: OrderItem): number {
if (isCountryInEU(this.user.address.country)) {
return currentPrice * 0.97;
}
return currentPrice;
}
}
export function getPriceRules(user: User): PriceRule[] {
return [
new UserPriceRule(user),
new UserAddressPriceRule(user),
]
}
/**********************
* userOrderModule.mts
*/
interface UserOrderHistory {
getOrdersCount: number;
}
class UserOrderPriceRule implements PriceRule {
constructor(protected orderHistory: UserOrderHistory) {};
computePrice(currentPrice: number, item: OrderItem): number {
if ((this.orderHistory.getOrdersCount > 0) && (this.orderHistory.getOrdersCount % 10 === 0)) {
return currentPrice * 0.96;
}
return currentPrice;
}
}
export function getPriceRules(user: User): PriceRule[] {
const userOrder = new UserOrderHistory(user);
return [
new UserOrderPriceRule(userOrder),
]
}
/**********************
* orderCodeModule.mts
*/
function isValidCode(code: string): boolean { ...; }
class OrderCodePriceRule implements PriceRule {
constructor(protected code: string) {};
computePrice(currentPrice: number, item: OrderItem): number {
if (isValidCode(this.code)) {
return currentPrice * 0.97;
}
return currentPrice;
}
}
export function getPriceRules(code: string): PriceRule[] {
return [
new OrderCodePriceRule(code),
]
}
/**********************
* purchasePromotionsModule.mts
*/
class DatePriceRule implements PriceRule {
computePrice(currentPrice: number, item: OrderItem): number {
if ((new Date()).getDay() === 6) {
return currentPrice * 0.93;
}
return currentPrice;
}
}
class ItemCountPriceRule implements PriceRule {
computePrice(currentPrice: number, item: OrderItem): number {
if (item.count >= 3) {
return currentPrice * 0.95;
}
return currentPrice;
}
}
export function getPriceRules(code: string): PriceRule[] {
return [
new DatePriceRule(),
new ItemCountPriceRule(),
]
}
/****************************
* programm.mts
*/
const priceEngine = new PriceEngine();
priceEngine.addRules(userModule.getPriceRules(currentUser));
priceEngine.addRules(userOrderModule.getPriceRules(currentUser));
priceEngine.addRules(orderCodeModule.getPriceRules(currentCode));
priceEngine.addRules(purchasePromotionsModule.getPriceRules());
console.log(priceEngine.execute({count: 2, price: 1.25}));
const noPromotionsPriceEngine = new PriceEngine();
noPromotionsPriceEngine.addRules(userModule.getPriceRules(currentUser));
noPromotionsPriceEngine.addRules(userOrderModule.getPriceRules(currentUser));
noPromotionsPriceEngine.addRules(orderCodeModule.getPriceRules(currentCode));
console.log(noPromotionsPriceEngine.execute({count: 2, price: 1.25}));
Jak vidíte na ukázkách, rules engine neví nic o implementačních detailech jednotlivých částí. Každý "modul" si drží implementační detaily u sebe. Rules engine vám navíc umožňuje mít více instancí procesu a plnit jej pravidly na základě dalších aplikačních pravidel.
Na této úrovni/velikosti pseudo-implementace je výsledný kód větší než původní. Nicméně s přidáváním dalších a dalších podmínek se samotný rules engine nezmění, pouze se přidají další pravidla. Úroveň komplexity a čitelnost zůstane zachována.