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

в 7:04, , рубрики: ajax, javascript, xmlhttprequest, метки: ,

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

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

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

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

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

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

(function () {
    "use strict";

    window.SomeClass = function () {

        var randNumber = Math.random();

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

        this.randomized = randNumber;

    };

})();

Если вы заметили, я сразу упаковал весь код в функцию, которую тут же вызвал, это обычная практика в JS, она используется во-первых для чистоты кода (не засоряем глобальный скоуп), и во-вторых из-за производительности (это вытекает из первого, дело в том, что когда вы создаете очень много переменных в одной области видимости (в данном случае в глобальной), то при обращении к ним, интерпретатор будет искать их дольше, т.к. ему придется перебрать больше вариантов, ведь поиск переменной начинается с самого близкого скоупа и идет вверх до глобального). Так же я использую use strict, можете почитать о нем здесь, он поможет избежать вам непредвиденных ситуаций, особенно если вы используете 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 (сводку по методам/св-вам можете посмотреть на русской страничке википедии). Нам же нужно намного больше – надо следить за ответом сервера, и если заметим 403-й ответ и 401 в теле, то в срочном порядке открываем форму логина. Но пользовательский callback при этом не должен быть вызван. И более того, после «аборта» должна быть возможность перезапустить запрос и получить ответ. Следовательно мы должны хранить в объекте-прокси все данные, которые передавались каким-либо методам-сеттерам (в т.ч. open и send) и при перезапуске запроса нужно заново вызвать все эти методы. Но вот проблема остается – представим ситуацию, когда был совершен запрос, окончился неудачей из-за разаутентификации, тогда, только после того как юзер залогиниться, мы должны перезапустить запрос и запустить событие onreadystatechange, но это событие не должно быть запущено до того как запрос будет запущен повторно. Решение простое – дело в том, что событие onreadystatechange запускается по крайней мере четыре раза, при этом свойство readyState инкрементируется (тут все его значения), так что нам нужно вызвать пользовательский 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

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js