Паттерн Стратегия на 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 += `
${count++}: ${msg}
`) && 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 += `
${this.count.value++}: ${msg}
`;} };

Теперь нам остаётся лишь переопределять экземпляр Логгера, создавая его от разных классов, что бы изменить 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 += `
${this.count++}: ${msg}
`;} 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 += `
${this.count++}: ${msg}
`;} };

Использование тоже весьма похоже на предыдущий вариант. Разве что не надо дополнительно 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;

должен cбиваться 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)

© Habrahabr.ru