Паттерн Стратегия на 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;

    должен 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 описанные выше — далеко не все возможные реализации этого паттерна. Я более чем уверен, что ты, читатель, сможешь сделать тоже самое ещё десятком других способов. Так что призываю взять на заметку именно идею Стратегии, а не мои жалкие попытки её реализовать.




    *Я очень люблю экстраполировать преувеличивать

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 13

      +5
      > Не скажу за другие языки, но JS чертовски гибок! Этот, как и другие паттерны, не зашиты в синтаксис

      Не поймите неправильно, я за паттерны, интерфейсы и мир во всем мире, но в чем смысл использования паттерна «Стратегия» в языке, где функции — это first class citizens (но нет интерфейсов)?
        +1
        В том, что бы иметь возможность на-горячую менять часть реализации, оставляя общий каркас неизменным. Плевать на язык и его возможности. эта задача мультиплатформенная (:
        Это используют например фреймворки для тестирования, логгеры, аутентификаторы и прочие инструменты позволяющие взаимодействовать с неограниченным количеством 3rd-part решений (будь-то JS код / БД / REST-сервисы и т.п.)
          0
          > что бы иметь возможность на-горячую менять часть реализации, оставляя общий каркас неизменным.
          > Это используют например фреймворки для тестирования, логгеры

          Я не сомневаюсь в нужности этого. Я пытаюсь сказать, что для того, чтобы передать один кусок кода в другой, в функциональном языке паттерн «Стратегия» не нужен.
            0
            JS не функциональный язык. Он конечно умеет в функции, но не нужно игнорировать его ООП составляющую.

            > для того, чтобы передать один кусок кода в другой, в функциональном языке
            Наверняка, но моя дурная голова не смогла родить красивое решение в функциональном стиле.

            Если вы знаете, как выполнить представленное выше ТЗ на JS в функциональном стиле, не прибегая к этому паттерну — умоляю, поделитесь кодом. Если все условия будут выполнены — обещаю добавить раздел с ним в статью, с пометкой вашего авторства.
        0
        return (...args) => logToDOM.apply(null,[...args,options.node]);

        Я думал что… были придуманы чтобы избавиться от костыля с apply.
        return () => logToDOM(...arguments, options.node);
        Вот так разве не покатит?
          0

          Почти. Идея хорошая, надо было сделать так:


           return (...args) => logToDOM(...args, options.node);

          Стрелочные функции не умеют в arguments

          +4
          > Попробуем сделать это чистыми функциями:
          > const logToDOM = (lvl,count,msg,node) => (node.innerHTML += `<div class="${lvl}">${count++}: ${msg}</div>`) && count;

          Подождите, вы же сказали про Чистые функции и сами дали определение «без побочного эффекта».
          При этом в примере у вас функция, которая имеет побочный эффект – изменяет состояние ноды.
            0
            блииин (: а это правильно подмечено… что ж. зря я выбрал вывод на страницу. Но главное, что основную идею статьи не ломает.
            Просто представьте что тут любой другой destination, который укладывается в понятие pure function.
              0
              Единственный destination, который укладывается в понятие pure function — это возврат результата. Но в этом случае сломается весь концепт сравнения.

              Самый простой пример паттерна «стратегия» в js называют callback.
              Pure function для этого не обязателен, но в целом возможен — Array.map(), Array.reduce().
                0
                Только изучаю функциональное программирование. Можете привести пример как оно будет выглядеть с читыми функциями. Хотя бы на словах.
              –2
              раскурите декораторы
                0
                function Logger(destination, lvl) {
                    return lvl => msg => destination(lvl, msg)
                }
                
                function logToConsole(methodForConsole = 'log') {
                    return (lvl, msg) => console[methodForConsole](`Level: ${lvl}, Message: ${msg}`)
                }
                
                function logToDom(node) {
                    return (lvl, msg) => node.innerHTML = msg
                }
                
                const loggerToConsole = Logger(logToConsole())
                const warnToConsole = loggerToConsole('warn')
                warnToConsole('Hello, world!')
                
                const loggerToDom = Logger(logToDom(document.querySelector('#output')))
                const errorToDom = loggerToDom('error')
                errorToDom('<h1 style="color:red;">Oh no!</h1>')
                

                Красиво, удобно, масштабируемо
                  0
                  1. нет счётчика
                  2. нельзя менять detination

                Only users with full accounts can post comments. Log in, please.