JavaScript-прокси: и красиво, и полезно

https://medium.com/dailyjs/how-to-use-javascript-proxies-for-fun-and-profit-365579d4a9f8
  • Перевод
В JavaScript, сравнительно недавно, появилась новая возможность, которая пока используется не особенно широко. Речь идёт о прокси-объектах. Прокси позволяют создавать обёртки для других объектов, организовывая перехват операций доступа к их свойствам и операций вызова их методов. Причём, это работает даже для несуществующих свойств и методов проксируемых объектов.

image

Представляем вашему вниманию перевод статьи программиста Альберто Химено, в которой он делится различными идеями использования объектов Proxy в JavaScript.

Первое знакомство с прокси-объектами


Начнём с основ. Вот простейший пример работы с прокси-объектом:

const wrap = obj => {
  return new Proxy(obj, {
    get(target, propKey) {
        console.log(`Reading property "${propKey}"`)
        return target[propKey]
    }
  })
}
const object = { message: 'hello world' }
const wrapped = wrap(object)
console.log(wrapped.message)

Этот код выводит следующее:

Reading property "message"
hello world

В этом примере мы выполняем некое действие до того, как дадим вызывающему механизму доступ к свойству или методу проксируемого объекта, а затем возвращаем это свойство или этот метод. Похожий подход применим и для перехвата операций изменения свойств путём реализации обработчика set.

Прокси-объекты могут быть полезны для проверки атрибутов или выполнения подобных действий, но я думаю, что возможная сфера их применения гораздо шире. Например, я полагаю, что должны появиться новые фреймворки, которые используют прокси в своей базовой функциональности. В размышлениях обо всём этом у меня родились некоторые идеи, которыми я хочу поделиться.

SDK для API в 20-ти строках кода


Как я уже сказал, объекты Proxy позволяют перехватывать вызовы методов, которых в проксированных объектах не существует. При вызове метода проксированного объекта вызывается обработчик get, после чего можно возвратить динамически сгенерированную функцию. При этом данный объект, если в этом нет необходимости, изменять не нужно.

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

Например, может существовать прокси-объект, который, при вызове api.getUsers(), может создать путь GET/users в API. Этот подход можно развивать и дальше. Например, команда вида api.postItems({ name: ‘Item name' }) может вызывать обращение к POST /items, используя первый параметр метода в виде тела запроса.

Посмотрим на программное выражение этих рассуждений:

const { METHODS } = require('http')
const api = new Proxy({},
  {
    get(target, propKey) {
      const method = METHODS.find(method => 
        propKey.startsWith(method.toLowerCase()))
      if (!method) return
      const path =
        '/' +
        propKey
          .substring(method.length)
          .replace(/([a-z])([A-Z])/g, '$1/$2')
          .replace(/\$/g, '/$/')
          .toLowerCase()
      return (...args) => {
        const finalPath = path.replace(/\$/g, () => args.shift())
        const queryOrBody = args.shift() || {}
        // Тут можно использовать fetch
        // return fetch(finalPath, { method, body: queryOrBody })
        console.log(method, finalPath, queryOrBody)
      }
    }
  }
)
// GET /
api.get()
// GET /users
api.getUsers()
// GET /users/1234/likes
api.getUsers$Likes('1234')
// GET /users/1234/likes?page=2
api.getUsers$Likes('1234', { page: 2 })
// POST /items с телом запроса
api.postItems({ name: 'Item name' })
// api.foobar не является функцией
api.foobar()

Здесь мы создаём обёртку для пустого объекта — {}, при этом все методы реализованы динамически. Прокси необязательно использовать с объектами, содержащими необходимую функциональность или её части. Значок $ используется как подстановочный символ для параметров.

Тут хотелось бы отметить, что вышеприведённый пример вполне допустимо реализовать иначе. Его, например, можно оптимизировать. Скажем, динамически генерируемые функции можно кэшировать, что избавит нас от необходимости постоянно возвращать новые функции. Всё это не имеет прямого отношения к прокси-объектам, поэтому я, чтобы не перегружать примеры, привожу их именно в таком виде.

Выполнение запросов к структурам данных с помощью удобных и понятных методов


Предположим, есть массив с информацией о неких людях и с ним надо работать примерно так:

arr.findWhereNameEquals('Lily')
arr.findWhereSkillsIncludes('javascript')
arr.findWhereSkillsIsEmpty()
arr.findWhereAgeIsGreaterThan(40)

Подобное можно реализовать с помощью прокси. А именно, массив можно обернуть прокси-объектом, который анализирует вызовы методов и выполняет поиск запрошенных данных в массиве.

Вот как это может выглядеть:

const camelcase = require('camelcase')
const prefix = 'findWhere'
const assertions = {
  Equals: (object, value) => object === value,
  IsNull: (object, value) => object === null,
  IsUndefined: (object, value) => object === undefined,
  IsEmpty: (object, value) => object.length === 0,
  Includes: (object, value) => object.includes(value),
  IsLowerThan: (object, value) => object === value,
  IsGreaterThan: (object, value) => object === value
}
const assertionNames = Object.keys(assertions)
const wrap = arr => {
  return new Proxy(arr, {
    get(target, propKey) {
      if (propKey in target) return target[propKey]
      const assertionName = assertionNames.find(assertion =>
        propKey.endsWith(assertion))
      if (propKey.startsWith(prefix)) {
        const field = camelcase(
          propKey.substring(prefix.length,
            propKey.length - assertionName.length)
        )
        const assertion = assertions[assertionName]
        return value => {
          return target.find(item => assertion(item[field], value))
        }
      }
    }
  })
}
const arr = wrap([
  { name: 'John', age: 23, skills: ['mongodb'] },
  { name: 'Lily', age: 21, skills: ['redis'] },
  { name: 'Iris', age: 43, skills: ['python', 'javascript'] }
])
console.log(arr.findWhereNameEquals('Lily')) // находит Lily
console.log(arr.findWhereSkillsIncludes('javascript')) // находит Iris

Очень похоже на то, что тут показано, может выглядеть написание с использованием прокси-объектов библиотеки для работы с утверждениями вроде expect.

А вот ещё одна идея использования прокси-объектов. Она заключается в создании библиотеки для построения запросов к базе данных со следующим API:

const id = await db.insertUserReturningId(userInfo)
// Выполняет запрос INSERT INTO user ... RETURNING id

Мониторинг асинхронных функций




Прокси-объекты позволяют перехватывать вызовы методов. Если вызов метода возвращает промис, возможности прокси позволяют узнать о том, когда этот промис разрешится. Эту возможность можно использовать для мониторинга асинхронных методов объекта и формирования статистических сведений по этим методам, которые, например, можно вывести в терминал.

Рассмотрим пример. Имеется объект service, реализующий некий асинхронный функционал, и функция monitor, которая принимает объекты, оборачивает их в прокси-объекты и организует наблюдение за асинхронными методами проксируемых объектов. Вот как, на высоком уровне, выглядит работа этих механизмов:

const service = {
  callService() {
    return new Promise(resolve =>
      setTimeout(resolve, Math.random() * 50 + 50))
  }
}
const monitoredService = monitor(service)
monitoredService.callService() // асинхронный метод, за которым нужно наблюдать

Вот полный пример:

const logUpdate = require('log-update')
const asciichart = require('asciichart')
const chalk = require('chalk')
const Measured = require('measured')
const timer = new Measured.Timer()
const history = new Array(120)
history.fill(0)
const monitor = obj => {
  return new Proxy(obj, {
    get(target, propKey) {
      const origMethod = target[propKey]
      if (!origMethod) return
      return (...args) => {
        const stopwatch = timer.start()
        const result = origMethod.apply(this, args)
        return result.then(out => {
          const n = stopwatch.end()
          history.shift()
          history.push(n)
          return out
        })
      }
    }
  })
}
const service = {
  callService() {
    return new Promise(resolve =>
      setTimeout(resolve, Math.random() * 50 + 50))
  }
}
const monitoredService = monitor(service)
setInterval(() => {
  monitoredService.callService()
    .then(() => {
      const fields = ['min', 'max', 'sum', 'variance',
        'mean', 'count', 'median']
      const histogram = timer.toJSON().histogram
      const lines = [
        '',
        ...fields.map(field =>
          chalk.cyan(field) + ': ' +
          (histogram[field] || 0).toFixed(2))
      ]
      logUpdate(asciichart.plot(history, { height: 10 })
        + lines.join('\n'))
    })
    .catch(err => console.error(err))
}, 100)

Итоги


Прокси-объекты в JavaScript — это очень мощный инструмент. Кроме того, возможность динамической реализации методов на основе их имён добавляет коду ясности и читабельности. Однако, как и любой другой дополнительный уровень абстракции, прокси создают некоторую нагрузку на систему. Я ещё не анализировал их производительность, вы же, если планируете использовать их в продакшне, проверьте сначала, как это отразится на быстродействии вашего проекта. Но, как бы там ни было, ничто не мешает применять прокси-объекты в ходе разработки, реализуя с их помощью, например, мониторинг асинхронных функций в отладочных целях.

Уважаемые читатели! Вот скидочка специально для вас :)

Пользуетесь ли вы прокси-объектами в своих JS-приложениях?

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

RUVDS.com 558,36
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией
Комментарии 13
  • 0
    Прокси — интересная штука. Но у вас какие-то дико искусственные примеры, по которым вообще не видно, как прокси облегчают жизнь.
    • 0
      Использовал прокси для создания базового класса модели, зависящей от нескольких источников информации. Очень упрощает реализацию дочерних классов.
      • +1
        Можете какой-то пример написать?
        • 0
          Получилась конструкция, которая работает наподобие git'a.

          Есть this._map с данными, которые записываются при загрузке модели из разных источников и this._index, в котором хранятся все изменения (diff) модели от this._map

          Естественно, остальные имплементации моделей уже расширяют данный класс.

          Base.js
          const VError = require('verror');
          
          const { fromComponentsSchemas } = require('../utils/schemaExtractor');
          
          const STATES = {
            UNDEFINED: 'UNDEFINED',
            CREATING: 'CREATING',
            LOADED: 'LOADED',
            DELETED: 'DELETED'
          };
          
          /**
           * Base class for our model structure, which takes the responsibility of getting and setting
           */
          class ExtendableProxy {
            constructor() {
              return new Proxy(this, {
                set: (target, property, value, receiver) => {
                  if (property.indexOf('_') !== 0) { // If not private property -> work with _index
                    if (this._state === STATES.UNDEFINED) { // Transition to CREATING state
                      this._state = STATES.CREATING;
                    }
          
                    if (!this._index[property] && this._map[property].value !== value) { // Check if not in index or not changed
                      this._index[property] = Object.assign({}, this._map[property]);
                      this._index[property].value = value;
                    } else if (this._index[property]) { // If exists -> needs to be updated
                      this._index[property].value = value;
                    }
          
                    return true;
                  }
          
                  target[property] = value;
                  return true;
                },
                get: (target, property, receiver) => {
                  // Check if not accessing the methods of the class
                  // and not the private properties
                  // and not symbol
                  // so we could validate the state of a model
                  if (typeof target[property] !== 'function' && typeof property !== 'symbol' && property.indexOf('_') !== 0) {
                    if (this._state === STATES.UNDEFINED) throw new Error('Model is in UNDEFINED state');
          
                    // TODO: Check if getter exists for this property and use it
                    return (this._index[property] && this._index[property].value) || (this._map[property] && this._map[property].value);
                  }
          
                  return target[property];
                }
              });
            }
          }
          
          class Base extends ExtendableProxy {
            /**
             * Sets the source map for Model's variables
             */
            constructor(deviceId, modelName) {
              super();
              this._state = STATES.UNDEFINED;
          
              this._deviceId = deviceId;
          
              this._map = fromComponentsSchemas(modelName).properties;
          
              this._index = {}; // Index of model modifications
          
              if (!this._map) throw new VError(`Schema ${modelName} is not found`);
            }
          
            /**
             * Loads whole model from different sources, must be overrided
             * @return {Promise.<T>}
             */
            load() {
              this._state = STATES.LOADED;
            }
          
            /**
             * Sends the data from model to different sources, must be overrided
             * @return {Promise.<T>}
             */
            commit() {
          
            }
          
            /**
             * Deletes the model in different sources, must be overrided
             * @return {Promise.<T>}
             */
            delete() {
              this._state = STATES.DELETED;
            }
          
            /**
             * Returns an array of names of modified variables
             * @return {Array}
             */
            difference() {
              if (this._state === STATES.UNDEFINED) return [];
          
              const diff = [];
          
              for (let key in this._index) {
                diff.push(key);
              }
          
              return diff;
            }
          }
          
          Base.STATES = STATES;
          
          module.exports = Base;
          

      • 0
        > Например, я полагаю, что должны появиться новые фреймворки, которые используют прокси в своей базовой функциональности.
        Не знаю насчет новых, но Vue в 3-й версии, вроде бы, обещают переписать с использованием Proxy
        • 0
          Вообще не понял из статьи зачем нужна такая штука в реальной жизни
          • +2
            Явное лучше не явного,
            api.getUsers() // magic
            api.call('GET', 'users') // explicit
            
            ">
            • 0
              Очень интересно, что там у проксей с производительностью. Особенно когда одну в другую оборачивать и так далее.
              • 0
                А еще они сами по себе довольно медленные. Сейчас правда в V8 их оптимизировали но еще не до конца.
              • 0
                Proxy отличная вещь, но от production ready его пока еще отделяет полное отсутствие поддержки в IE (11 всё еще актуален)
                • 0
                  В свое время написал прокси сервис, который позволял делать любые апи запросы.

                  Как пример:
                  GET /users/1234/books?limit=10
                  POST /users/1234 {name: «User1»}
                  Выглядит так:
                  var response = await api.get.users(1234).books({limit:10})
                  var response = await api.post.users(1234)({name: "User1"})
                  


                  Под капотом он обращался к апи, парсил ответ и генерировал ошибку если пошло что-то не так или возвращал ответ апи сервера
                  • 0
                    Другое (в комментариях)

                    Ещё вчера не использовал, но для сегодняшней задачки должно подойти как нельзя лучше.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое