Патерн Стратегія на JavaScript

Раніше я вже публікував переклад статті з такою ж назвою. І під нею товариш aTei залишив коментар:
По-моєму дечого не вистачає в цій статті та у статті в википедие — прикладу в стилі «Було погано — стало добре». Відразу виходить «добре» та не досить ясно, що це дійсно добре. Буду вдячний за такий приклад.
Відповіді на нього так ніхто і не дав досі. За 3 роки я набрався досвіду сміливості і тепер, як відповідь на цей коментар, хочу написати про паттерне Стратегія від свого імені.
Крихти теорії зустрічаються десь за текстом. Але більша частина статті присвячена практичним способам застосування цього патерну і варіантів його застосування уникнути.
Дано: написати Логгер, який дозволяє:
  • Писати логи 3х рівнів:
    log
    ,
    warn
    та
    error
  • Вибирати destination (призначення) для логів: console, сторінка (обрані для наочності)
    • Одноразово
    • Безліч разів

  • Додавати нові destination не вносячи змін в код Логер. Наприклад file, ftp, mysql, mongo, etc.
  • Додавати номер (кількість викликів) запис лода
  • Використовувати декілька незалежних Логгерів
Другий пункт передбачає єдиний "інтерфейс", що б не довелося заради зміни destination переписувати всі рядки, де зустрічається виклик Логер.


Альтернативи
Спершу наведу два варіанти "рішення" навмисне уникають ознак Стратегії.
Функціональний підхід
Спробуємо зробити це чистими функціями:
По-перше, нам знадобляться дві функції які будуть виконувати основну роботу:
const logToConsole = (lvl,count,msg) => console[lvl](`${count++}: ${msg}`) || count;

const logToDOM = (lvl,count,msg,node) =>
(node.innerHTML += `<div class="${lvl}">${count++}: ${msg}</div>`) && count;

Обидві вони виконують свою основну функцію, а потім повертають нове значення
count
.
По-друге, потрібен якийсь єдиний інтерфейс їх об'єднує. І на цьому ми зустрічаємо першу проблему… Так як чисті функції не зберігають станів, не можуть впливати на зовнішні змінні і не мають інших сайд-ефектів — у нас практично не залишається інших варіантів, крім як захардкодить вибір destination всередині основної функції. Наприклад так:
const Logger = (options) => {
switch(options.destination){
case 'console': return logToConsole;
case 'dom': return (...args) => logToDOM.apply(null,[...args,options.node]);
default: throw new Error(`type '${type}' is not availible`);
};
};

Тепер, оголосивши в клієнтському коді всі необхідні змінні, ми можемо використовувати наш Логгер:
let logCount = 0;
const log2console = {destination: 'console'};
const log2dom = {
destination: 'dom'
,node: document.querySelector('#log')
};

let logdestination = log2console;
logCount = Logger(logdestination)('log',logCount,'this goes to console');
logdestination = log2dom;
logCount = Logger(logdestination)('log',logCount,'this goes to dom');

Думаю недоліки такого підходу очевидні. Але найголовніше — він не задовольняє третій умові: Додавати нові destination не вносячи змін в код Логер. додавши новий destination ми повинні внести його в
switch(options.destination)
.
Результат. Увімкніть DevTools console перед перемиканням на вкладку Result

ООП підхід
В попередній раз ми були скуті неможливістю зберігати стану, із-за чого вимагали від клієнтського коду створення і підтримки оточення, потрібного нашому Логгеру для роботи. В ООП стилі ми можемо сховати все це "під капот" — властивості екземплярів або класів.
Створимо абстрактний клас, в якому, для зручності роботи з нашим Логером, опишемо високорівневі методи:
log
,
warn
та
error
.
Крім того, нам знадобиться властивість
count
(я зробив його властивістю прототипу
Logger
і об'єктом, що б воно було глобальним, і сабклассы з примірниками прототипно наслідували його, а не створювали свою копію. Нам же не потрібні різні лічильники для різних destination?)

class Logger {
log(msg) {this.write('log',msg);}
warn(msg) {this.write('warn',msg);}
error(msg) {this.write('error',msg);}
};
Logger.prototype.count = {value:0};

2 "робочі конячки" як і в минулий раз:
class LogToConsole extends Logger {
write(lvl, msg) {console[lvl](`${this.count.value++}: ${msg}`);}
};
class LogToDOM extends Logger {
constructor(node) {
super();
this.domNode = node;
}
write(lvl,msg) {this.domNode.innerHTML += `<div class="${lvl}">${this.count.value++}: ${msg}</div>`;}
};

Тепер нам залишається лише змінити примірник Логер, створюючи його від різних класів, що б змінити destination:
let logger = new LogToConsole;
logger.log('this goes to console');
logger = new LogToDOM(document.querySelector('#log'));
logger.log('this goes to dom');

Цей варіант вже не має недоліком функціонально підходу — дозволяє писати destination незалежно. Але, в свою чергу, не задовольняє останньому умові: Використовувати декілька незалежних Логгерів. Так як зберігає
count
в статичному властивості класу
Logger
. А значить всі екземпляри будуть мати один загальний
count
.
Результат. Увімкніть DevTools console перед перемиканням на вкладку Result

Стратегія
насправді я схитрував, складаючи умови завдання: Будь-яке рішення, яке задовольняє всім їм буде реалізовувати патерн Стратегія в тому чи іншому вигляді. Адже його основна ідея — організувати код так, що б виділити реалізацію будь-яких методів (зазвичай "внутрішніх") в окрему, абсолютно незалежну сутність. Таким чином, що б
  • по-перше, створення нових варіацій цієї сутності не зачіпав основний код
  • по-друге, підтримувати "гарячу" (plug-n-play) заміну цих сутностей вже під час виконання коду.
Стратегія на "брудних" функції
Якщо відмовитися від чистоти функції
Logger
, і скористатися замиканням — ми отримаємо ось таке рішення:
const Logger = () => {
var logCount = 0;
var logDestination;
return (destination,...args) => {
if (destination) logDestination = (lvl,msg) => destination(lvl,logCount,msg,...args);
return (lvl,msg) => logCount = logDestination(lvl,msg);
};
};

Функції
logToConsole
та
logToDOM
залишаються колишніми. Залишається лише оголосити примірник Логер. А для заміни destination — передавати потрібний цього примірника.
const logger = Logger();
logger(logToConsole)('log','this goes to console');
logger(logToDOM,document.querySelector('#log'));
logger()('log','this goes to dom');

Результат. Увімкніть DevTools console перед перемиканням на вкладку Result

Стратегія на прототипах
Під минулим постом, товариш tenshi висловив думку:
І що ж заважає змінити LocalPassport на FaceBookPassport під час роботи?
Чим підкинув ідею для наступної реалізації. Прототипних спадкування — напрочуд потужна і гнучка штука. А з легалізацією властивості
.__proto__
— просто чарівна. Ми можемо на-ходу міняти клас (прототип) від якого успадковується наш екземпляр.
Скористаємося цією махінацією:
class Logger {
constructor(destination) {
this.count = 0;
if (destination) this.setDestination(destination);
}
setDestination(destination) {
this.__proto__ = destination.prototype;
};
log(msg) {this.write('log',msg);}
warn(msg) {this.write('warn',msg);}
error(msg) {this.write('error',msg);}
};

Так, тепер ми можемо чесно поміщати
count
в кожен примірник Логер.
LogToConsole
буде відрізнятися лише викликом
this.count
замість
this.count.value
. А ось
LogToDom
зміниться значніше. Тепер ми не можемо використовувати
constructor
для завдання
.domNode
, адже ми не будемо створювати екземпляр цього класу. Зробимо для цього метод сетер
.setDomNode(node)
:
class LogToDOM extends Logger {
write(lvl,msg) {this.domNode.innerHTML += `<div class="${lvl}">${this.count++}: ${msg}</div>`;}
setDomNode(node) {this.domNode = node;}
};

Тепер для зміни destination потрібно викликати метод
setDestination
, який замінить прототип нашого екземпляра:
const logger = new Logger();
logger.setDestination(LogToConsole);
logger.log('this goes to console');
logger.setDestination(LogToDOM);
logger.setDomNode(document.querySelector('#log'));
logger.log('this goes to dom');

Результат. Увімкніть DevTools console перед перемиканням на вкладку Result

Стратегія на інтерфейсах
Якщо ви загуглите "Патерн Стратегія" то в будь* зі статей ви зустрінете згадка інтерфейсів. І так вийшло, що в будь-якому* іншій мові: інтерфейс — це конкретна синтаксична конструкція, що володіє конкретним унікальним функціоналом. На відміну від JS… Мені здається, що саме з цієї причини мені так важко давався цей патерн в свій час. (Та кого я обманюю? досі нивзубногой як воно працює).
Якщо по-простому: Інтерфейс дозволяє "зобов'язати" імплементації (реалізації) володіти конкретними методами. Не дивлячись на те, як ці методи реалізовані. Наприклад в класі
Людина
оголошений інтерфейс
Мова
з методами
привітатися
та
попрощатися
. А вже конкретний екземпляр
вася
може використовувати різні імплементації цього інтерфейсу:
російська
,
англійська
,
русскаяМатерная
. І навіть змінювати їх час від часу. Так що при "включеної" імплементації
російська
, наш
вася
використавши метод
привітатися
інтерфейсу
Мова
— виголосить "Привіт". А коли "включена"
англійська
, то ж дія спонукає його сказати 'Hello'.
Я не міг втриматися від приведення прикладу цього патерну в його "класичному" вигляді, використовує інтерфейси. Для чого накидав невелику бібліотеку реалізує концепцію інтерфейсів в JS — js-interface npm
Зовсім коротенький лікнеп по тому синтаксису який буде використаний в прикладі:
const MyInterface = new jsInterface(['doFirst','doSecond']); // створює Інтерфейс оголошує методи .doFirst (..).doSecond(..)

MyInterface(object,'prop'); // призначає властивості .prop цей інтерфейс.
// тепер Object.keys(object.prop) -> ['doFirst','doSecond'] завжди*

object.prop = implementation; // вказує/вказує імплементацію для методу.
// implementation може бути як об'єктом. так і конструктором - головне, що б методи doFirst і doSecond мало.

Цей підхід буде дуже близький до попереднього. У коді
Logger
тільки рядки пов'язані з
destination
заміняться одним з jsInterface, а метод
write
перенесеться до властивості
loginterface
:
class Logger {
constructor() {
this.count = 0;
jsInterface(['write'])(this,'loginterface');
}
log(msg) { return this.loginterface.write('log',msg); }
warn(msg) { return this.loginterface.write('warn',msg); }
error(msg) { return this.loginterface.write('error',msg); }
};

Поясню код вище. У конструкторі ми оголошуємо в примірника
new Logger
властивість інтерфейс
loginterface
з методом
write
.
LogToConsole
не вимагає для себе зберігання будь-яких даних, так що зробимо його простим об'єктом
log2console
з методом
write
:
const log2console = {
write:function(lvl,msg) {console[lvl](`${this.count++}: ${msg}`);}
};

А ось
LogToDOM
потребує зберіганні
node
. Щоправда, тепер його можна загорнути в замикання і не захаращувати примірник Logger зайвими властивостями і методами.
function LogToDOM(node) {
this.write = function(lvl,msg) {node.innerHTML += `<div class="${lvl}">${this.count++}: ${msg}</div>`;}
};

Використання теж дуже схоже на попередній варіант. Хіба що не треба додатково
setDomNode
викликати.
const logger = new Logger();
logger.loginterface = log2console;
logger.log('this goes to console');
logger.loginterface = new LogToDOM(document.querySelector('#log'));
logger.log('this goes to dom');

Ви напевно помітили таку особливість: Після
logger.loginterface = log2console;

повинен збиватися
this.count
. адже:
logger.log('bla bla') ->
-> this.loginterface.write('log','bla bla') ->
-> log2console.write('log','bla bla')
this.count === log2console.count

Але в цьому і "магія" інтерфейсів. Імплементації — не "самостійні" об'єкти — вони лише надають код своїх методів у користування "справжнім" об'єктам, у яких цей інтерфейс оголошено. Так що ланцюжок перетворень буде така:
logger.log('bla bla') ->
-> this.loginterface.write('log','bla bla') ->
-> log2console.write.apply(logger,['log','bla bla'])
this.count === logger.count

Результат. Увімкніть DevTools console перед перемиканням на вкладку Result

Підсумок
Стратегія є одним з базових патернів. Таким, який часто реалізується інтуїтивно, без усвідомленого слідування заповідям будь-якого підручника.
Не скажу за інші мови, але JS біса гнучкий! Цей, як і інші патерни, не зашиті в синтаксис — реалізуйте його так, як це зручно і там де це зручно.
Звісно 3 описані вище — далеко не всі можливі реалізації цього патерну. Я більш ніж впевнений, що ти, читачу, можеш зробити теж саме ще десятком інших способів. Так що закликаю взяти на замітку саме ідею Стратегії, а не мої жалюгідні спроби її реалізувати.


*Я дуже люблю екстраполювати перебільшувати
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.