Обмен сообщениями между документами и RPC

Ойвинд Шон Кинси (Øyvind Sean Kinsey) | 29 июня 2010 года

Хотя часто по соображениям безопасности мы не хотим, чтобы страницы из разных доменов могли обмениваться данными, иногда это необходимо. И тогда мы обнаруживаем, что "правильного" способа сделать это не существует — текущие стандарты и технологии созданы так, чтобы запрещать это. Поэтому мы ищем обходные пути — используем динамические теги script с включением из внешних доменов, используем JSONP или делаем все возможное, используя postMessage или технику IFrame URL (FIM). Такие решения часто становятся довольно сложными и хрупкими, и передача чисто строковых сообщений между доменами может в результате составить большую часть кода.

Типичные междоменные сценарии:

  • Документ в домене foo.com с окном iframe, указывающим на домен bar.com, в котором один из документов пытается обратиться к элементам, свойствам или коду из другого документа
  • Документ в домене foo.com пытается использовать объект XMLHttpRequest для загрузки URL-адресов из домена bar.com

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

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


Поэтому перед тем как начать, давайте определим несколько качеств, которыми должно обладать любое решение.

  • Только данные могут пересекать границы (это значит, что строки как объекты могут представлять угрозу безопасности)
  • Сообщения может читать только тот получатель, для которого они предназначены
  • Получатель должен быть способен определить, кто является отправителем сообщения, чтобы избежать спуфинга
  • Сообщения должны доставляться надежно
  • Должны поддерживаться сообщения произвольного размера
  • Не должно быть утечки контекста (одно окно не должно ссылаться на объекты, принадлежащие другому окну)
  • Решение должно работать одинаково во всех целевых браузерах

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

Доступные методы

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

postMessage

Поддержка браузеров: Доступен в IE8, Firefox 3, Chrome 2, Safari 4 и Opera 9

Этот компонент определен в черновой версии выходящего вскоре стандарта HTML5, но уже реализован во всех основных браузерах. Стандарт определяет метод window.postMessage и соответствующее событие сообщения, которое позволяет одному документу передавать сообщения, а другому — регистрировать их получение.

Пример

foo.com 
    <iframe src="http://bar.com/" id="barFrame">
    </iframe>
 
    var win = document.getElementById("barFrame").contentWindow;
    win.postMessage("hola!", "http://bar.com"); // обеспечение того, что только http://bar.com сможет получить это сообщение
 
bar.com 
    window.addEventListener("message", function(message) {
        if (message.origin == "http://foo.com") { // обеспечение получения сообщений только от доверенного узла
            alert(message.data);
        }
    });

Недостатки:

  • Поддерживается только относительно новыми браузерами

Сообщения с идентификатором фрагмента

Поддержка браузеров: Доступно во всех браузерах

Это вероятно самая известная техника, которая работает благодаря небольшой лазейке в SOP. Чтобы один документ мог перейти в другой, разрешен доступ для записи к свойству document.location, а это значит, что можно записать данные, передав их в url-адрес. Так как обычно мы не хотим перезагружать документ с которым происходит обмен данными, мы используем тот факт, что если задать для свойства location тот же url-адрес, изменив только часть после " # ", то перезагрузка не произойдет. А так как документ, в который выполняется запись, имеет полный доступ для чтения к своему свойству location, он сможет легко извлечь записанные данные.

foo.com 

    <iframe src="http://bar.com/" id="barFrame">
    </iframe>

    var win = document.getElementById("barFrame").contentWindow;
    win.location = "http://bar.com/#hola!";

bar.com 

    var prevMsg = location.hash;
    window.setInterval(function(){
        if (location.hash !== prevMsg) {
            prevMsg = location.hash;
            alert(prevMsg);
        }
    }, 100);

Недостатки

  • Затруднительно оповещение о том, когда может ожидаться сообщение. Использование таймеров не рекомендуется для сохранения ресурсов
  • Нет способа идентификации отправителя
  • Сообщения могут поступать быстрее, чем получатель сможет их прочитать
  • Разные браузеры имеют разные максимальные размеры для URL-адресов. Например, IE6 поддерживает URL-адреса с максимальной длиной 4095 символов.

'window.name'

Поддержка браузеров: Доступно во всех браузерах, которые сохраняют свойство имени окна при переходах через домены

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

Пример

 

foo.com 
 
    <iframe src="http://bar.com/" name="barFrame" id="barFrame">
    </iframe>
    <iframe src="sender.html" id="helper">
    </iframe>
 
    var helper = document.getElementById("helper").contentWindow;
    helper.sendMessage("hola!", "http://bar.com/receiver.html"); //функция sendMessage определена в sender.html
 
foo.com/sender.html 
 
    window.sendMessage = function(message, url){
        window.name = message;
        location.href = url;
    };
bar.com 
 
    function onMessage(message) {
        alert(message);
    }
 
bar.com/receiver.html 
 
    parent.frames["barFrame"].onMessage(window.name); // передача сообщения далее
    window.history.back(); // возвращение в документ sender.html

Недостатки

  • Требуются дополнительные вспомогательные документы и дополнительные окна
  • Требуются дополнительные запросы сервера (если не используется интенсивное кэширование)

'NIX'

Поддержка браузеров: Доступно в IE6 и IE7

Это определенно одна из наименее известных техник, и она привлекла мое внимание, когда я изучал исходный код проекта Apache Shindig. Она работает благодаря тому, что свойство окон iframe opener доступно для записи из родительского окна, и в то же время доступно для чтения из самого окна iframe, а также может хранить не только примитивы, но и любые другие типы объектов. Это можно использовать для обмена функциями для передачи сообщений в обоих направлениях.

Пример

 

foo.com 
 
    <iframe src="http://bar.com/" id="barFrame">
    </iframe>
 
    var postMessage;
    function onMessage(msg) {
        alert(msg);
    }
     
    var win = document.getElementById("barFrame").contentWindow;
    win.opener = {
        setPostMessage: function(fn) {
            postMessage = fn;
    },
    postMessage: function(msg) {
        window.setTimeout(function(){ //задерживает вызов, чтобы он выполнялся в 'правильном' 'контексте'
            onMessage(msg);
        },0);
    }
    };
    window.setTimeout(function(){ //ожидает, пока не загрузится дочерний документ и не будет задана функция postMessage
        postMessage("hola!");
    }, 100);
 
bar.com 
 
    function onMessage(msg) {
        alert(msg);
    }
    window.opener.setPostMessage(onMessage);
    window.opener.postMessage("right back at ya");

Недостатки

  • Раскрывает контекст окон.  Одно окно может легко получить доступ к "привилегированным" данным, а не только к тем, которые передаются.
  • Многочисленные проблемы, связанные с безопасностью, несанкционированным доступом, атаками типа "злоумышленник в середине" и т. д.
  • Нет способа идентификации отправителя.

Кроме вышеупомянутых методов также существуют техники типа JSONP для получения данных и междоменные POST-запросы для отправки данных, но общим для них является то, что для сохранения состояния необходимы серверные компоненты, и поэтому они не являются в полном смысле слова решениями для обмена данными между документами, когда оба документа способны сохранять контекст и состояние.

Использование нормализованного API

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

/**
* @param {String} msg Сообщение для транспортировки
* @param {String} recipient Домен предопределенного получателя  
*/
function postMessage(msg, recipient) {
    // реализация
}
 
/**
* @param {String} msg Сообщение
* @param {String} origin Домен исходящего окна
*/
function onMessage(msg, origin) {
    // обработка сообщения 
}

Пока все хорошо, но в чем на самом деле смысл транспортировки на основе строк? Не лучше ли вместо этого вызывать методы, передавать аргументы и использовать возвращенные значения? Оказывается, на самом деле это совсем не трудно, если доступны два других ресурса, протокол JSON-RPC, который определяет, как форматировать строку с данными, необходимыми для вызова метода и получения его возвращаемого значения, и сериализатор/десериализатор JSON (собственный или из библиотеки JSON2 Дугласа Крокфорда (Douglas Crockford)). С этими инструментами (строковый транспорт, протокол для удаленного вызова процедур (RPC), использующий строковые сообщения, и сериализатор JSON) мы вплотную подошли к реализации междоменного RPC.

Начнем с вызова процедуры:

Согласно спецификации JSON-RPC, вызов должен иметь следующий формат

'{"jsonrpc":"2.0", "method": "methodName", "params:" ["a", 2, false]}'

Этот формат легко можно получить при сериализации стандартного объекта javascript с помощью метода JSON.stringify. А после транспортировки строки через границу можно восстановить объект javascript с помощью метода JSON.parse.

Пример

Примечание. В этом примере предполагается наличие рабочего транспорта между документами.

foo.com 
 
var procedureCall = {
    jsonrpc: "2.0",
    method: "alertMessage",
    params: ["my message"]
};
//сериализация сообщения
var jsonRpcString = JSON.stringify(procedureCall);
//отправка сообщения
postMessage(jsonRpcString, "http://bar.com"); // http://bar.com — это предопределенный получатель
 
bar.com 
 
function alertMessage(msg) {
        alert(msg);
}
function onMessage(msg, origin) {
    //десериализация сообщения
    var procedureCall = JSON.parse(msg);
    //выполнение вызова
    switch (procedureCall.method) {
        case "alertMessage":
            // вызов запрошенного метода с переданными аргументами
            alertMessage.apply(null, procedureCall.params);
            break;
        case ....
    }
}

Это было не очень сложно, не так ли? Теперь, если мы хотим также иметь возможность вызывать методы и получать возвращаемые значения, мы можем отметить сообщение параметром идентификатора, чтобы можно было связать возвращаемые значения с их обработчиками.

Пример

foo.html 
 
var calls = {}, callId = 0;
 
function onMessage(msg, origin) {
    //десериализация сообщения
    var procedureCall = JSON.parse(msg);
    if (procedureCall.method) { // это вызов метода
        switch(procedureCall.method) {
            ...
        }
    }else{ // это результат
        // получение функции обратного вызова
        var fn = calls[procedureCall.id];
        // ее выполнение с результатом 
        fn(procedureCall.result);
        // удаление обратного вызова
        delete calls[procedureCall.id];
    }
}
 
function rpcAdd(a, b, fn) {
    var id = ++callId; //получение нового идентификатора
    calls[id] = fn; //сохранение функции, которая должна получить возвращаемое значение
    postMessage(JSON.stringify({
        jsonrpc: "2.0",
        method: "add",
        params: [a, b],
        id: id
    }), "http://bar.com");
}
 
// очень похоже на любой асинхронный вызов, правда?
rpcAdd(3, 5, function(result) {
    alert("результат: " + result);
});
 
bar.com 
 
function add(a, b) {
    return a + b;
}
 
function onMessage(msg, origin) {
    //десериализация сообщения
    var procedureCall = JSON.parse(msg);
    //выполнение вызова
    switch (procedureCall.method) {
        case "add":
            // вызов запрошенного метода с переданными аргументами
            var result = add.apply(null, procedureCall.params);
            //возврат значения
            postMessage(JSON.stringify({
                jsonrpc: "2.0",
                id: procedureCall.id,
                result: result
            }), "http://foo.com");
            break;
        case ....
    }
}

И снова задание оказалось нетрудным, если есть правильные инструменты :)

Итак, что необходимо для реализации RPC между документами?

Теперь, чтобы все это могло использоваться надежно, необходимо следующее

  • Следует создать код для каждой доступной техники, чтобы выполнить условия, заданные во введении. Это означает добавление надежности, организацию очереди, проверку отправителя и получателя и т. д. И сделать их полнодуплексными (работающими в обе стороны).
  • Разные транспорты должны быть абстрагированы так, чтобы они имели общий интерфейс, и включены в логику, выбирающую подходящий транспорт
  • Транспорты должны правильно инициализироваться перед использованием, вручную или автоматически через строку запроса
  • Необходимо добавить логику для создания заглушек RPC, для обработки вызовов и уведомлений RPC и для обработки всех граничных случаев для RPC

Поверьте, это не делается за час или два!

Решение

К счастью, вам не нужно проходить все вышеперечисленные этапы, так как уже существует платформа, которая все это сделает за вас. easyXDM — это библиотека JavaScript, которая позволяет разработчику легко обойти ограничение, установленное SOP, и выполняет все необходимые для этого действия с помощью качественного кода JavaScript. Она предоставляет два уровня абстракций: класс Socket, который используется для обмена строковыми сообщениями, и класс RPC, который обеспечивает удаленные вызовы процедур.

Использование класса easyXDM.Socket

Чтобы настроить транспорт, нужно передать в easyXDM всего один аргумент — URL-адрес на удаленной стороне. Все остальное, в том числе передачу необходимых параметров другой стороне, обрабатывает easyXDM.

Пример

foo.com 
 
//Чтобы включить использование NameTransport, необходимо передать несколько дополнительных аргументов, но это не будет рассматриваться здесь.
 
var socket = new  easyXDM.Socket({
    // это URL-адрес на другой стороне, поставщик. Этот адрес нужен только для главного документа, получателя.
    remote: "http://foo.com/index.html",
    // onMessage — это функция, которая будет вызваться входящими сообщениями.
    onMessage: function(msg, origin) {
        alert("получено сообщение:" + msg + " от " + origin);
    },
    // функция onReady вызывается, когда транспорт готов к использованию.
    onReady: function(){
        // здесь можно разместить код, который должен выполняться, когда транспорт работает и готов к использованию
    }
});
 
// у каждого объекта Socket есть метод "postMethod", который можно использовать для отправки сообщений
// сообщения, отправленные до запуска события onReady, будут буферизованы и выполнены при наступлении готовности
socket.postMessage("hola!");
 
bar.com/index.html 
 
// на стороне поставщика easyXDM автоматически настраивает транспорт на основании данных, переданных в URL-адрес.
var socket = new easyXDM.Socket({
    onMessage: function(msg, origin) {
        alert("получено сообщение:" + msg + " от " + origin);
    }
});
socket.postMessage("hola!");

Попробуйте демонстрацию

Этот сокет поддерживает все браузеры — IE6+, IE8, Firefox 3, Chrome 2, Safari 4 и Opera 9 со скоростью передачи менее 15 мс, а скорость остальных зависит от базового транспорта. Он также соответствует всем требованиям, которые мы определили ранее, и еще некоторым.

Использование класса easyXDM.Rpc

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

Пример

foo.com 
 
// первый передаваемый объект практически идентичен объекту, переданному в конструктор Socket
var rpc= new easyXDM.Rpc({
    remote: "http://foo.com/index.html"
},
// здесь мы определяем методы, которые необходимо предоставить, а также удаленные методы, для которых должны создаваться заглушки
{
    // здесь мы определяем методы, которые необходимо предоставить
    local: {
        barFoo: function(a, b, c, fn){
            // здесь можно реализовать предоставленный метод.
            // если это синхронный метод, то можно использовать
            // 'return value;' для возврата значения
            // если метод асинхронный (например, выполняющий код ajax), то мы используем
            // fn(value);
            return a + b.toString() + c.toString();
        }
    },
    // определение заглушек, которые должен создать easyXDM
    remote: {
        fooBar: {}
    }
});
 
// отправка уведомления JSON-RPC 2.0 (функции обратного вызова не вызываются)
rpc.fooBar();
 
bar.com/index.html 
 
// повторю, нам не нужно передавать какие-либо сведения относительно транспорта, это обрабатывается автоматически
var rpc= new easyXDM.Rpc({}, {
    local: {
        fooBar: function(){
            // эта функция была вызвана с помощью уведомления JSON-RPC, давайте выполним обычный вызов функции
            rpc.barFoo("a", 1, false, function(result) {
                alert("результат функции rpc.barFoo " + result);
            });
        }
    },
    remote: {
        barFoo: {}
    }
});

Попробуйте демонстрацию

Как видите, экземпляр класса RPC будет дополнен заглушками для всех методов, что сделает его использование аналогичным любому другому асинхронному методу.

Сценарии использования

easyXDM — это на самом деле просто платформа, которая упрощает обмен сообщениями между документами и RPC, и ее можно представить как строительный блок — сама по себе она делает немного, но в качестве основы она может творить чудеса.

Важное замечание об iframe

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

По умолчанию окно будет скрыто от просмотра, так как таким образом работает большинство API, но easyXDM также поддерживает видимые окна.

Пример

var rpc= new easyXDM.Rpc({
        remote: "http://foo.com/index.html",
        onReady: function(){
            ....
        },
        container: document.getElementById("container"),
        props: {
            style: {
                border: "1px solid red",
                width: "100px",
                height: "200px"
            }
        }
    }, {
    local: {
        ...
    },
    remote: {
        ...
    }
});

Как видите, мы передаем контейнер для управления расположением iframe в DOM, а также объект props, который содержит все свойства, которые должны быть применены к iframe. Объект props копируется глубоко в iframe, поэтому мы можем использовать 'style: {...}' для задания стиля.

Заключение

Добавление обмена данными между документами в приложения могло бы оказаться сложным, но на самом деле это не так — для этой задачи просто нужно использовать правильный инструмент. easyXDM — один из таких инструментов — гибкий, надежный и очень простой в использовании, так как он выполняет все трудные задачи за вас. И, как я уже упоминал, это полная платформа, независимая и совместимая с различными браузерами. Использование easyXDM регулируется лицензией MIT; дополнительные сведения см. на веб-сайте easyXDM, а также в соответствующей документации по API.