Rules engine

Rules engine

architekturanávrhové vzory

Headshot 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

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.

Headshot Bronislav Klučka, 03. 8. 2025 21:01

Komentáře

Napište komentář

Komentáře jsou schvalované, než se zobrazí na stránkách.

Nebylo možné přidat komentář.

Váš komentář byl přidán a čeká se na schválení.

Váš komentář je příliš dlouhý.

Používáme cookies a podobné technologie, jako Google Analytics, pro sběr analytických dat. To nám pomáhá pochopit, jak uživatelé používají naše stránky.

Více info

Stránky používají Google Analytics, a analytické služby poskytované společností Google. Google Analytics používá cookies, aby nám tato služba pomohla analyzovat, jak uživatelé používají naše stránky. Informace generované cookies, které se týkají vašeho používání stránek (včetně vaší IP adresy) budou přeneseny a uloženy u společnosti Google. Požíváme tato data pro vytváření reportů o aktivitě a k poskytování dalších služeb, které se týkají těchto stránek.

Analytická data nám pomáhají vylepšovat naše služby. Nepoužíváme je k marketingovým a reklamním účelům. Tato data nepředáváme ani neprodáváme dál.