jsonex – упрощаем сложные клиент-серверные диалоги

в 11:01, , рубрики: client-server, IT-стандарты, javascript, json, Веб-разработка, метки: ,

jsonex – упрощаем сложные клиент серверные диалоги

Взаимодействие между клиентом и сервером как правило устроено очень просто и опирается на довольно примитивный инструментарий. Это не создает проблем само по себе, но зачастую даже небольшое усложнение поставленной задачи плохо укладываться в привычные подходы, порождая не слишком изящные решения-заплатки. Многие задачи решаются в каждом новом проекте заново, бессистемно и независимо друг от друга. К подобным задачам относятся, например:

  • Batch-запросы
  • Передача даты в составе сложной структуры данных
  • Обозначение кастомных типов данных
  • Проброс round-trip данных, которые сервер должен вернуть в ответе
  • Дополнение запроса и ответа метаданными
  • Обработка ошибок, пришедших в ответе

Разработчики тратят немало времени, раз за разом создавая неказистые велосипеды на серверной стороне, после чего их приходится поддерживать еще и на стороне клиента.

jsonex представляет собой попытку объединить решение упомянутых выше и многих других задач в рамках простого единого подхода, основанного на концепции вычислимых данных (callable data).

Содержание

jsonex
Нотация вызова (call notation)
Контекст
Связь jsonex и JS
Вычислимые данные (callable data)
Взгляд со стороны клиента
Преимущества вычислимых данных
Работа с HTTP и веб сокетами
Соображения безопасности
JSON-представление
Асинхронные вызовы
Arc
Заключение

jsonex

Концепция вычислимых данных проста и может быть использована для самых разных форматов данных. Чуть дальше я расскажу, как использовать ее в рамках JSON-представления. Но, чтобы продемонстрировать идею в чистом виде, начнем издалека.

Во многих случаях мы используем JSON, и он прекрасен. Он прост, легко читается, позволяет представлять иерархические структуры данных, широко поддерживается. Тем не менее, некоторые вещи хотелось бы улучшить, например:

  • Разрешить комментарии
  • Позволить использовать апострофы для строк
  • Позволить опускать кавычки для ключей в словарях
  • Разрешить завершающие запятые в словарях и списках

Наш расширенный вариант JSON (назовем его jsonex) мог бы выглядеть, например, так:

{ // Пользователь
  name: 'John',
  familyName: 'Smith',
  dateOfBirth: '1901-01-01',
  friendIds: [
    124124,
    283746, /* завершающая запятая */
  ],
  num: 123,
}

Уже неплохо. Многое бы отдал за возможность писать комментарии в JSON-конфигах. Но есть в этом примере одна весьма сомнительная строчка:

  dateOfBirth: '1901-01-01',

Что это? Строка? Дата? Человек может догадаться по контексту, но парсер вряд ли окажется столь же догадлив. Тип даты не предусмотрен форматом. Чтобы его распознать, можно использовать два подхода – описать схему данных, либо использовать какую-то подсказку-аннотацию, которая могла бы направить парсер в нужном направлении.

JSON изначально не требует схемы, и было бы странно требовать ее наличия только ради возможности подсказать парсеру, в каком из полей лежит дата. Поэтому пойдем вторым путем. Можно придумать множество способов добавить аннотацию для даты, но было бы неплохо иметь простой и в то же время универсальный способ обозначать любые типы данных. Для этой цели замечательно подойдет нотация вызова.

Нотация вызова (call notation)

Запишем наше поле так:

  dateOfBirth: Date('1901-01-01'),

Теперь тип данных выглядит очевидным. Но что конкретно должен сделать парсер, встретив подобную запись? Подход достаточно прямолинеен. Встретив конструкцию вида SomeName(args...) парсер должен:

  • Найти у себя в закромах заранее заданную функцию-обработчик с именем SomeName
  • Выполнить эту функцию над аргументами args...
  • Использовать результат ее выполнения в обработанных данных вместо изначальной конструкции

Таким образом, результат разбора полностью зависит от реализации функции-обработчика. Наш вызов Date('1901-01-01') превратится в объект типа Date в JavaScript, date или datetime в Питоне и так далее.

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

Может показаться, что, обрабатывая нотации вызова, мы попросту выполняем содержащийся в данных произвольный кусочек кода, но это не так:

  • Данные остаются данными и контроль над обработкой полностью лежит на нашей стороне
  • Парсер не может использовать никакие функции кроме заданных явным образом обработчиков
  • Любая функция-обработчик определена нами и может выполнить любые предварительные проверки, прежде чем совершать активные действия

Далее мы рассмотрим эти моменты более подробно. А пока заметим, что нотация вызова дает нам просто потрясающую гибкость. Добавляя новые обработчики мы можем легко расширять систему:

var handlers = {
  Date: function (ctx, v) {
    return new Date(v);
  },
  Complex: function (ctx, real, imag) {
    return new Complex(real, imag);
  },
  ArrayBuffer: function (ctx, v) {
    return base64DecToArr(v).buffer;
  },
  Person: function (ctx, personDataDict) {
    return new Person(personDataDict);
  }
};

// это не настоящий парсер, просто пример каким он мог бы быть
var person = new JsonexParser(handlers).parse(
  "Person({"+
  " name: 'John',"+
  " dateOfBirth: Date('1901-01-01'),"+
  " i: Complex(0, 1),"+
  " song: ArrayBuffer('Q2FsbCBub3RhdGlvbiBpcyBjb29sIQ=='),"+
  "})"
);

Здесь мы добавили возможность парсинга дат, комплексных чисел, бинарных данных в base64-представлении – все это буквально в несколько строк кода и совершенно не меняя устройство нашего гипотетического парсера.

Контекст

Как вы могли заметить, каждый обработчик, помимо собственных аргументов, принимает параметр ctx. В этом параметре передается контекст обработки. На данном этапе будем считать, что ctx – это изначально пустой словарь. Благодаря контексту мы можем:

  • Позволить обработчикам «общаться» друг с другом
  • Передавать обработчикам какие-то настройки
  • Передавать обработчикам дополнительные данные из внешнего окружения
  • Сохранять дополнительную информацию, полученную в процессе парсинга

Например, с помощью контекста легко создать обработчики get и set, которые позволят использовать вычисленные ранее объекты:

var handlers = {
  // кладем данные в коробку и возвращаем их же
  set: function (ctx, key, data) {
    ctx.box = ctx.box || {};
    ctx.box[key] = data;
    return data;
  },
  // возвращаем данные, взятые из коробки
  get: function (ctx, key) {
    return ctx.box ? ctx.box[key] : undefined;
  }
};
var data = new JsonexParser(handlers).parse(
  "[ set('x', { a: 'a' }), get('x') ]"
);
data[0].a = 5; // в data[0] и data[1] лежит ссылка на один и тот же объект
console.log(JSON.stringify(data)); // [{"a":5},{"a":5}]

Связь jsonex и JS

Так же, как и JSON, jsonex тесно связан с синтаксисом JS. Он является синтаксически корректным JS-выражением и в простейших случаях даже может быть вычислен как JS-выражение, при условии, что каждая нотация вызова определена в контексте вычисления. Например,

[
  foo(),
  bar.baz() // да, jsonex позволяет точки в именах вызовов
]

является корректным и, более того, вычислимым JS-выражением при условии, что foo и bar правильным образом определены.

Это, конечно, не означает, что стоит брать и вычислять jsonex с помощью eval(), определив необходимые переменные в соответствующем замыкании. Кроме потенциальных проблем с безопасностью, этот подход теряет часть гибкости, которую дает возможность проанализировать данные именно как данные, а не как нечто выполняемое. Тем не менее, в некоторых случаях jsonex действительно можно рассматривать как ограниченное подмножество JS и считать jsonex-данные JS-выражением.

Вычислимые данные (callable data)

Данные в jsonex представляют собой вычислимое выражение, которое может быть легко проанализировано перед вычислением или прямо в процессе вычисления. Почему бы не использовать такие выражения в качестве запросов к серверу? Например, запрос мог бы выглядеть так:

getUsers([1, 15, 7])

Сервер мог бы вычислить его с помощью соответствующего обработчика:

var handlers = {
  getUsers: function (ctx, userIds) {
    var listOfUsers = getUsersFromDbOrWhatever(userIds);
    return listOfUsers;
  }
};

Затем сериализовать результат в jsonex и отправить клиенту ответ:

[ User({id: 1, name: 'John', ...}), ...]

Клиент получит данные с честными объектами, готовыми к использованию. В то же время сервер превращается в простой вычислитель jsonex-выражений. Для расширения API достаточно добавить новый обработчик – не надо возиться с урлами, разбирать аргументы, приводить их к нужным типам, различать GET и POST, все заработает само.

Взгляд со стороны клиента

Давайте подумаем, как организовать вызов со стороны клиента. Собирать jsonex-представление вручную в виде строк типа "getUsers([1, 15, 7])" было бы неудобно. Поэтому нам пригодится вспомогательный объект, описывающий нотацию вызова и понимаемый сериализатором. Вот так могло бы выглядеть его использование:

var getUsers = function (userIds) {
  return new jsonex.Call('getUsers', userIds); // вспомогательный объект
};
// пример использования гипотетического сериализатора
jsonex.stringify(getUsers([1, 15, 7])); // 'getUsers([1,15,7])'

В таком случае, запрос к серверу может выглядеть так:

server.ask(
  getUsers([1, 15, 7]), // запрос
  function (err, result) { // обработка ответа
    ...
  }
);

server.ask() должен сделать следующее:

  • Превратить первый аргумент в jsonex
  • Отправить его в качестве запроса на сервер
  • Дождаться jsonex-ответа и распарсить его
  • Отдать полученный результат в callback-функцию

В нашем примере первый аргумент будет значением, которое вернет getUsers(), то есть объектом типа jsonex.Call, который сериализуется в строку 'getUsers([1,15,7])'

Выглядит просто и симпатично. С точки зрения написания кода, все действия производятся над готовыми к использованию объектами, любые преобразования спрятаны под капотом. В примере использован callback, но при использовании Promise, все будет выглядеть еще приятнее.

Если полученный от сервера результат является наследником класса Error, то server.ask() считает, что сервер вернул ошибку и вызывает callback с соответствующими аргументами. Такой подход возможен, поскольку результат парсинга является готовым к использованию объектом нужного класса.

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

Пример ответа сервера с сообщением об ошибке:

UnexpectedError('Error details message')

Пример обработчика:

handlers.UnexpectedError = function(ctx, msg) {
  return new ServerError(msg); // ServerError должен быть наследником Error
};

Преимущества вычислимых данных

Что нам дают вычислимые данные:

  • Возможность указывать типы передаваемых объектов
  • Стандартизированную обработку ошибок – достаточно вернуть объект нужного типа
  • Элементарно реализуются batch-запросы
  • Batch-запросы на стероидах – результаты одних вызовов могут быть использованы в качестве аргументов других
  • Легко пробрасывать round-trip данные
  • Легко передать дополнительную информацию – любые заголовки и метаданные, которая должны быть учтены при обработке, но не должны смешаваться с остальными данными

Batch-запрос, например, может выглядеть так:

[
  getFoo(),
  getBar(1, 2, 3),
]

В ответ придет массив с результатами вызовов getFoo() и getBar().

Используем результат одного вычисления в другом:

[
  set('x', getUserBooks(17)), // получить книги пользователя 17
  getAuthors( // получить авторов книг
    getProps( // вычленить свойство 'authorId' для каждого объекта из get('x')
      get('x'), 'authorId'
    )
  ),
]

Ответом будет массив со списком книг и списком авторов этих книг.
Примечание: В данном примере вызов getProps() может быть потенциально опасен, представляя возможность дотянуться до свойств, которые вы возможно не хотели раскрывать – будьте осторожны с реализацией подобных обработчиков.

Передача round-trip данных:

[
  137, // данные, которые вернутся, например, id запроса
  someRequest(...)
]

В ответе будет массив с числом 137 и результатом вызова someRequest().
Примечание: В реальности нам пришлось бы использовать более сложную конструкцию, чтобы обеспечить возвращение round-trip данных, даже если во время обработки someRequest() будет брошено исключение.

Передача дополнительных данных:

last( // возвращает последний аргумент
  metaInfo('метаданные или типа того', 1, 3, 4),
  someRequest(...)
)

Здесь вызов metaInfo() может что-то изменить в контексте, вызвать дополнительные действия или еще как-то повлиять на обработку, но его возвращаемое значение не попадет в ответ, так как last() вернет только свой последний аргумент.

Работа с HTTP и веб сокетами

HTTP-запрос, помимо основных данных (тела запроса), содержит путь, метод и заголовки. HTTP-ответ содержит код возврата. При использовании jsonex удобно использовать единый путь для всех запросов – так же, как это обычно делается для batch-запросов и при взаимодействии с помощью веб сокетов. Можно раскидать API и по разным путям, но это редко имеет смысл.

HTTP метод нам не нужен, поскольку каждый запрос может включать любые вызовы, как получающие данные, так и изменяющие их. Тем не менее, поддержка различных HTTP методов может быть полезна или даже необходима для обеспечения правильной работы с браузерами, прокси-серверами и прочим HTTP-миром. Ее легко реализовать – достаточно добавить в контекст вычисления объекты request и response и наши обработчики смогут учитывать тонкости HTTP-протокола. То же касается кода возврата. Он не нужен в рамках jsonex-вычислений, но для правильного взаимодействия с HTTP-окружением стоит выставлять его правильно.

Что касается передачи самих данных, то все замечательно, когда они передаются в теле HTTP-запроса. В большинстве случаев так оно и будет, поскольку использование метода POST для jsonex-запросов выглядит разумным. Но если для каких-то целей используется GET, HEAD или DELETE, то придется передавать данные как часть URL, поскольку согласно стандарту тело этих запросов должно игнорироваться. Для этого есть простой и дешевый способ – передавать jsonex в единственном параметре query string, например query. Таким образом, запрос getUsers([1,2,3]) превратится в обращение по адресу http://example.com/api?query=getUsers%28%5B1%2C2%2C3%5D%29

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

Для передачи метаданных можно использовать как HTTP-заголовки, так и возможности jsonex:

last(
  authToken('myAuthToken'), // теперь сервер знает, кто я
  someOtherHeader('blah blah'),
  getUsers([1, 15])
)

Поскольку для веб сокетов нет штатного способа передавать заголовки с каждым сообщением, возможность интегрировать подобные данные в сам запрос очень удобна. Для веб-сокетов также важна возможность передавать round-trip данные и отсутствие необходимости указывать путь и метод для каждого запроса.

Для HTTP также важна идемпотентность запросов. Это свойство определяется HTTP методом: одни методы обязаны быть идемпотентными, другие – нет. Поскольку jsonex-запрос может представлять собой смесь идемпотентных и неидемпотентных вызовов, нам нужен механизм, позволяющий что-то с этим делать. Например, можно взводить флаг, требующий идемпотентности и проверять его в вызовах:

var handlers = {
  idempotent: function (ctx) {
    ctx.mustBeIdempotent = true;
  },
  updateUser(ctx, userData) {
    if (ctx.mustBeIdempotent) {
      throw new NonIdempotentCallError('updateUser');
    }
    ...
  }
};

Пример запроса:

last(
  idempotent(),
  [
    getUsers([1,2]),
    updateUser({id:1, ...}) // бросит исключение
  ]
)

Какие из вызовов идемпотентны, должно быть понятно из документации API, а вызов idempotent() даст нам уверенность, что в сложном запросе не использовано ничего опасного.

Соображения безопасности

При создании обработчиков, нужно помнить, что в запросе они могут быть вызваны с любыми аргументами и в любых комбинациях. Чтобы это не приводило к проблемам с безопасностью, следует придерживаться некоторых ограничений:

  • Обработчики не должны выполнять потенциально опасных для системы действий
  • Обработчики обязаны проверять свои аргументы и не допускать непредусмотренного использования
  • Обработчики обязаны проверять количество данных в своих аргументах, ограничивая размер пакета обработки разумной величиной
  • Обработчики должны проверять права доступа, если такая возможность предусмотрена системой
  • Общая вычислительная сложной запроса должна быть ограничена

Последний пункт накладывает ограничение на запрос в целом и заслуживает отдельного рассмотрения. Самый простой способ контролировать сложность запроса – рассчитывать ее по мере вычисления и останавливаться, если превышен некоторый порог:

handlers.expesiveCall = function (ctx, args...) {
    ctx.cost += calcCost(args...);
    if (ctx.cost > ctx.costThreshold) {
      throw new TooExpensiveError();
    }
    ...
  }
};

При расчете стоимости нужно учитывать аргументы, поскольку характер и количество переданных в них данных может сильно влиять на вычислительную сложность.

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

Еще один подход – запускать вычисления в песочнице с искусственно ограниченным доступом к ресурсам. Если окружение позволяет делать это несложно и дешево, это может быть неплохим вариантом.

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

В предельном варианте, ряд обработчиков может быть разрешен к использованию только по одиночке, либо в комбинации с совсем уж примитивными вызовами типа Date(). Эти обработчики по сути вернутся к привычной модели, не позволяющей более одного вызова в запросе. Вычислимые данные предоставляют широкие возможности, но при необходимости их несложно ограничить.

JSON-представление

Если вы хотите использовать jsonex прямо сейчас, вы столкнетесь с одной проблемой – в отличие от JSON, высокопроизводительные библиотеки парсинга и сериализации jsonex, мягко говоря, еще не столь широко доступны ) Но выход есть – можно использовать jsonex, опираясь на его JSON-представление. jsonex превращается в JSON применением трех простых правил:

// нотация вызова
f(...) => {"?": ["f", ...]}
// использование свойства '?'
{'?': value, ...} => {"?": ["?", value], ...}
// специальный случай, чисто для удобства
f({...}) => {"?": "f", ...}

Первое правило показывает, как записать в JSON-представлении нотацию вызова. При этом словарь, имеющий свойство '?' обретает особое значение. Второе правило отвечает на вопрос, как записать обычный словарь со свойством '?' так, чтобы не спутать его с нотацией вызова. А третье является синтаксическим сахаром, специальной формой записи нотации вызова для случаев, когда она имеет единственный аргумент и этот аргумент является словарем. Вот пример данных в jsonex и их же в JSON-представлении:

Person({ // класс Person
  name: 'John',
  dateOfBirth: Date('1901-01-01'),
  i: Complex(0, 1),
  d: { '?': 123 }
})

JSON-представление:

{
  "?": "Person",
  "name": "John",
  "dateOfBirth": {"?": ["Date", "1901-01-01"]},
  "i": {"?": ["Complex", 0, 1]},
  "d": {"?": ["?", 123]}
}

JSON-представление выглядит сложнее, но обозначает то же самое. Его можно распарсить стандартным JSON.parse(), а затем довычислить вторым проходом. Либо, в некоторых простых случаях, его можно вычислить прямо во время парсинга при помощи reviver-функции, передаваемой в JSON.parse(). То же самое касается сериализации в JSON-представление – ее легко сделать с помощью replacer-функции, передаваемой в JSON.stringify().

Вообще говоря, концепцию вычислимых данных можно добавить в качестве расширения практически к любому формату данных.

Асинхронные вызовы

В примерах, показанных ранее, неявно считалось, что все вызовы синхронны. Рассмотрим один из них внимательнее:

[
  set('x', getUserBooks(17)),
  getAuthors(getProps(get('x'), 'authorId')),
]

getUserBooks() и getAuthors(), вероятно, должны обращаться в некое хранилище данных, задействовать ввод-вывод и, соответственно, быть асинхронными. Значит мы не можем вычислить их на месте. А даже если можем (например, используя fibers), то все равно хотелось бы иметь возможность выполнять независимые асинхронные вызовы параллельно, а не один за другим.

Решением мог бы быть некий вычислительный движок, который помещал бы асинхронные вызовы в очередь на исполнение, а затем подставлял бы полученные результаты в нужные места. Тогда, вычислив все синхронные части, мы дождались бы выполнения асинхронных и после этого считали, что вычисление готово. В качестве очереди на выполнение можно использовать что-нибудь вроде async.queue(), выполняя задачи с заданным уровнем параллелизма.

Но на самом деле задача сложнее. В нашем примере вычисление одних вызовов зависит от других, мы не можем вычислить set(), пока не вычислим getUserBooks(). Поэтому при попытке вызвать set(), мы должны отложить это вычисление, указав всем вычислениям, от которых оно зависит, что, как только они будут готовы, необходимо довычислить set(). Здесь мы зависим ровно от одного отложенного вычисления – getUserBooks(), но возможны и более сложные зависимости.

Но и это еще не все. Вызов get() не способен вернуть ничего полезного, пока не выполнится set(). Поэтому get() тоже должен стать отложенным вычислением, на этот раз ожидающим сигнала от set(). В свою очередь getProps() зависит от get(), а getAuthors() зависит от getProps().

Наш вычислительный движок должен будет учесть все эти зависимости и выполнить все отложенные вычисления в нужном порядке. Это кажется не очень простой задачей, поэтому хотелось бы иметь реализацию, доказывающую работоспособность подхода.

Arc

Проект arc был создан, чтобы показать, что концепция вычислимых данных жизнеспособна в том числе в случае использования асинхронных обработчиков и зависимых вызовов. Сам движок очень прост, он состоит из нескольких сотен строк кода и на данный момент не содержит никаких хитрых оптимизаций, что делает его очень понятным.

Исходными данными для движка является поток токенов. В настоящее время токенизация доступна только для JSON-представления, но работа с jsonex напрямую не за горами. Arc может использоваться как в node.js так и в браузере при помощи browserify. Написание асинхронных обработчиков для arc выглядит очень просто, достаточно обернуть асинхронный вызов в соответствующую директиву, а всю сложную работу движок выполнит под капотом:

handlers.getUserBooks = function (ctx, userId) {
  return ctx.async(function (cb) {
    doSomethingAsync(...args, cb);
  });
};

Пока поддерживается только колбэки, поддержка Promise запланирована. Примеры использования движка, а также сериализации в jsonex и JSON-представление можно увидеть в соответствующем разделе.

Заключение

И сам jsonex, и arc в данный момент находятся в процессе разработки. Возможности jsonex, упомянутые в этой статье, вероятно не изменятся, но добавятся новые, такие как пространства имен (namespaces), бинарные части (binary chunks), потоковые данные (streams) и области (scopes). Arc, вероятно, изменится довольно сильно.

В силу новизны и необкатанности представленных идей, я бы рекомендовал использовать их с определенной долей осторожности. Тем не менее, я буду очень рад, если какие-то из них вам понравились и окажутся полезными в ваших проектах и экспериментах. Также буду признателен, если вы поделитесь своими впечатлениями, опытом и открытиями.

Автор: dimsmol

Источник

Поделиться

* - обязательные к заполнению поля