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

В 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


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


6bfe14a27908a8c1dbe18184ad3376d1.png

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

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

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

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru