- PVSM.RU - https://www.pvsm.ru -

Подмена XMLHttpRequest или как не трогая тонны готового js-кода изменить поведение всех ajax-запросов

Здравствуйте, в этой маленькой заметке расскажу немного про ООП в JS, объект XMLHttpRequest, паттерн прокси, и дружелюбие джаваскрипта в этом плане.

Была у меня сегодня такая задача — есть проект, который довольно активно использует ajax-запросы, но вот проблема — бекенд у нас так устроен, что разаутентифицирует пользователя, если тот не активен в течение, скажем, полу часа. В итоге случалось такое, что пользователь, пытаясь совершить какое-то действие, которое использует аякс, не мог его совершить (уж извините за тавтологию), нужно было решить эту проблему.

ТЗ, которое я для себя поставил

Если совершается аякс-запрос и в ответ приходит *что-то, говорящее о том, что у пользователя завершилась сессия*, нужно отобразить пользователю форму входа (обычным оверлейем, без всяких айфреймов), и дать ему возможность аутентифицироваться через нее (опять же по аяксу, т.к. нельзя потерять состояние страницы). Более того, если он пройдет аутентификацию, аякс-запросы, которые не прошли тогда, нужно отправить заново. Но вот первая проблема — нужно это сделать так, чтобы js-код, который опирается на этот запрос ничего не почувствовал, то есть нужно чтобы все callback-и сработали как надо и когда надо (они не должны сработать тогда, когда окажется, что нужна аутентификация, но должны тогда, когда она будет пройдена). И вторая проблема идет от асинхронности запросов — их может быть много, может получиться так, что сразу несколько запросов столкнуться с этой проблемой, надо наблюдать за всеми и, если нужно, перезапускать их после аутентификации. И, да, *что-то, говорящее о том, что у пользователя завершилась сессия* — в нашем случае это код ответа «403» и тело ответа «401» (401, потому что близко по духу, но нельзя из-за потребности в WWW-Authenticate, а просто 403 нельзя т.к. вообще по-хорошему с аутентификацией это никак не связано, но, хотя бы, близко).

Как же я люблю Javascript

Не долго думая я пришел к решению — воспользовавшись паттерном «прокси», создать, собственно, объект-проксю, и подменить им XMLHttpRequest (XHR), а в самом этом проксе уже общаться напрямую с XHR-ом. И да, представьте себе, в Javascript-е возможно подменить класс другим классом, фактически тут класс — это тот же объект (или прототип, в терминологии я не силен :( ).
Итак для начала, как же вообще создавать класс, инстанс которого можно будет получить с помощью new ClassName() и как вообще туда добавлять методы и свойства? Тут есть несколько способов, чтобы увидить все можете погуглить [1], я воспользовался наверное самым простым, вот как выглядит определение класса у нас:

(function () {
    "use strict";

    window.SomeClass = function () {

        var randNumber = Math.random();

        this.someMethod = function () {
            console.log(randNumber);
        };

        this.randomized = randNumber;

    };

})();

Если вы заметили, я сразу упаковал весь код в функцию, которую тут же вызвал, это обычная практика в JS, она используется во-первых для чистоты кода (не засоряем глобальный скоуп), и во-вторых из-за производительности (это вытекает из первого, дело в том, что когда вы создаете очень много переменных в одной области видимости (в данном случае в глобальной), то при обращении к ним, интерпретатор будет искать их дольше, т.к. ему придется перебрать больше вариантов, ведь поиск переменной начинается с самого близкого скоупа и идет вверх до глобального). Так же я использую use strict, можете почитать о нем здесь [2], он поможет избежать вам непредвиденных ситуаций, особенно если вы используете IDE (и особенно если используете JsLint/JsHint). И о коде – как видите мы создали «класс» SomeClass в глобальной области видимости, фактически конструктором этого класса является весь код внутри функции. В итоге мы имеем переменную randNumber, которая видная только изнутри класса (точнее его экземпляра), метод someMethod(), который шлет в консоль всегда одно и то же число для одного и того же экземпляра класса, и свойство randomized, которое равно этому же числу.
Делаем подмену:

(function () {
    "use strict";

    // сохраним оригинальный объект, т.к. без него не сможем слат запросы
    var XHR = window.XMLHttpRequest;

    window.XMLHttpRequest = function () {

        // создаем экземпляр оригинала
        var o = new XHR(),
            t = this,
            reassignAllProperties = function reassign() {
                t.readyState = o.readyState;
                t.responseText = o.responseText;
                t.responseXML = o.responseXML;
                t.status = o.status;
                t.statusText = o.statusText;
            };
        
        t.readyState = 0;
        t.responseText = "";
        t.responseXML = null;
        t.status = null;
        t.statusText = "";

        // просто подменим все методы, не меняя никак поведение
        // но добавим вызов reassignAllProperties() т.к. после
        // вызова любого из методов может быть изменено какое-то св-во
        t.open = function open() {
            o.open.apply(o, arguments);
            reassignAllProperties();
        };

        t.send = function send() {
            o.send.apply(o, arguments);
            reassignAllProperties();
        };

        t.abort = function abort() {
            o.abort();
            reassignAllProperties();
        };

        t.setRequestHeader = o.setRequestHeader;

        t.overrideMimeType = o.overrideMimeType;

        t.getResponseHeader = o.getResponseHeader;

        t.getAllResponseHeaders = o.getAllResponseHeaders;

        t.onreadystatechange = function () {};


        o.onreadystatechange = function onReady() {
            reassignAllProperties();
            t.onreadystatechange();
        };

    };

})();

Как видно из кода, он полностью повторяет оригинальный XMLHttpRequest (сводку по методам/св-вам можете посмотреть на русской страничке википедии [3]). Нам же нужно намного больше – надо следить за ответом сервера, и если заметим 403-й ответ и 401 в теле, то в срочном порядке открываем форму логина. Но пользовательский callback при этом не должен быть вызван. И более того, после «аборта» должна быть возможность перезапустить запрос и получить ответ. Следовательно мы должны хранить в объекте-прокси все данные, которые передавались каким-либо методам-сеттерам (в т.ч. open и send) и при перезапуске запроса нужно заново вызвать все эти методы. Но вот проблема остается – представим ситуацию, когда был совершен запрос, окончился неудачей из-за разаутентификации, тогда, только после того как юзер залогиниться, мы должны перезапустить запрос и запустить событие onreadystatechange, но это событие не должно быть запущено до того как запрос будет запущен повторно. Решение простое – дело в том, что событие onreadystatechange запускается по крайней мере четыре раза, при этом свойство readyState инкрементируется (тут все его значения [4]), так что нам нужно вызвать пользовательский callback только тогда, когда мы будем уверены, что ответ легитимный. Но, если где-то используются состояния отличные от «complete», нужно это учесть, проще всего тогда будет запустить событие три раза с тремя последними readyState (от 2 до 4), просто в цикле. Также нужно хранить все запросы, завершившиеся неудачей, которые нужно будет перезапустить после этого.

Последние штрихи

(function () {
    "use strict";

    // сохраним оригинальный объект, т.к. без него не сможем слать запросы
    var XHR = window.XMLHttpRequest,
        // здесь будет хранить все завершившиеся неудачей запросы, которые ожидают ретрая
        failedRequestsPool = [],
        authenticationWindow = function () {
            $("#auth-overlay").show();
        };

    $("#auth-overlay form").submit(function () {
        $.ajax({
            type: "post",
            url: "/login",
            data: {
                login: $("#auth-login").val(),
                password: $("#auth-password").val()
            },
            dataType: "json",
            success: function (data) {
                if (data.state === "OK") {
                    $("#auth-overlay").hide();

                    // если прошли логин, нужно перезапустить все ожидающие запросы
                    for (var i in failedRequestsPool) {
                        if (failedRequestsPool.hasOwnProperty(i)) {
                            failedRequestsPool[i].retry();
                        }
                    }
                    failedRequestsPool = [];
                }
            }
        });

        return false;
    });

    window.XMLHttpRequest = function () {

        // создаем экземпляр оригинала
        var o = new XHR(),
            t = this,
            // этот флаг понадобится, чтобы не пропустить плохой ответ до callback-а
            aborted = false,
            reassignAllProperties = function reassign() {
                t.readyState = o.readyState;
                t.responseText = o.responseText;
                t.responseXML = o.responseXML;
                t.status = o.status;
                t.statusText = o.statusText;
            },
            // будем хранить все переданные данные здесь, чтобы при ретрае снова передать их
            data = {
                open: null,
                send: null,
                setRequestHeader: [],
                overrideMimeType: null
            };

        t.readyState = 0;
        t.responseText = "";
        t.responseXML = null;
        t.status = null;
        t.statusText = "";


        t.retry = function retry() {
            aborted = false;

            // снова передаем все данные
            o.open.apply(o, data.open);

            reassignAllProperties();

            for (var i in data.setRequestHeader) {
                if (data.setRequestHeader.hasOwnProperty(i)) {
                    o.setRequestHeader.apply(o, data.setRequestHeader[i]);
                }
            }

            if ("overrideMimeType" in o && data.overrideMimeType !== null) {
                o.overrideMimeType(data.overrideMimeType);
            }

            o.send(data.send);

            reassignAllProperties();
        };

        // просто подменим все методы, не меняя никак поведение
        // но добавим вызов reassignAllProperties() т.к. после
        // вызова любого из методов может быть изменено какое-то св-во
        t.open = function open() {
            data.open = arguments; // запомним, для случая ретрая
            o.open.apply(o, arguments);
            reassignAllProperties();
        };

        t.send = function send(body) {
            data.send = body;
            o.send(body);
            reassignAllProperties();
        };

        t.abort = function abort() {
            o.abort();
            reassignAllProperties();
        };

        t.setRequestHeader = function setRequestHeader() {
            data.setRequestHeader.push(arguments);
            o.setRequestHeader.apply(o, arguments);
        };

        // зметьте что в IE может не быть этого метода, поэтому проверим
        if ("overrideMimeType" in o) {
            t.overrideMimeType = function (mime) {
                data.overrideMimeType = mime;
                o.overrideMimeType(mime);
            };
        }

        t.getResponseHeader = o.getResponseHeader;

        t.getAllResponseHeaders = o.getAllResponseHeaders;

        t.onreadystatechange = function () {};


        o.onreadystatechange = function onReady() {
            reassignAllProperties();

            // если еще не остановили запрос и если видим при этом, что нужно, то останавливаем
            if (!aborted && o.state === 403 && o.responseText.indexOf("401") !== -1) {
                aborted = true;
                o.abort();
                failedRequestsPool.push(t);
                authenticationWindow();
            }

            // если не был остановлен и уже готов ответ, то даем знать
            if (!aborted && o.readyState === 4) {
                for (var i = 1; i < 5; ++i) {
                    t.readyState = i;
                    t.onreadystatechange();
                }
            }
        };

    };

})();

Вот так, довольно просто, мы подменили XHR на прокси, который не даст упустить запросы, отправленные после «разаутентификации» пользователя.

ПС я заметил одну ошибку, метод getAllResponseHeaders() в опере выкидывает WRONG_THIS_ERR, только совершенно непонятно откуда это.

Автор: nikita2206


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/11738

Ссылки в тексте:

[1] погуглить: https://www.google.com/search?q=javascript+class+definition

[2] здесь: https://developer.mozilla.org/en/JavaScript/Strict_mode

[3] страничке википедии: http://ru.wikipedia.org/wiki/XMLHttpRequest

[4] значения: http://msdn.microsoft.com/en-us/library/ie/ms534361(v=vs.85).aspx