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

Здравствуйте, в этой маленькой заметке расскажу немного про ООП в 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, только совершенно непонятно откуда это.
ППС давайте не будем обсуждать как лучше было решить исходную задачу. дело в том, что данное решение может пригодиться не только в случае разаутентификации

UPD Если вам действительно нужно будет сделать такую подмену (а я советую подумать перед этим, т.к., как многие заметили в комментариях, подменять корневые библиотеки — очень не хорошо), то пользуйтесь этим решением github.com/ilinsky/xmlhttprequest

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 39

    +5
    У меня при решении такой задачи всегда возникает вопрос целесообразности таких трудозатрат.

    Самый простой способ, который приходит в голову, сделать авторизованному пользователю по таймауту (например 10-15 минут, зависит от настроек сессии на сервере) аякс-запрос на спец страницу, на которой продлевать сессию (с минимальными затратами), чтобы пользователь не разавторизовывался.
      +1
      Полностью согласен тут. Более того, я думаю, что вообще не стоит разаутентифицировать по тайм-ауту. Вообще я сделал именно так, а не иначе, потому-что: править бекенд — не хотелось, там очень страшно и можно навлечь на себя плохих духов; делать вашим способом — просто не интересно :), ну и мой несколько надежней в итоге, т.к. лучше не опираться что-то, выполняющееся по тайм-ауту (это мое имхо, но, думаю, не только мое)
        0
        Это приходит с годами. А когда даже в терминологии плохо разбираешься, приходится делать, как автор статьи ).
          0
          Лично я бы тоже так делал. Мало того, навесил бы на эту функцию еще и какое-то полезное действие, например узнать, как там статусы каких-то задач поживают. И все это работало бы при помощи стандартной библиотеки.

            0
            Ребят, давайте обсуждать решение тут, а не проблему, которую я с помощью него решил
              +1
              А что обсуждать?

              Создать очередь для запросов, если не хочется что-то терять. Удалять из очереди обработанные запросы (статус 200), вызывать колбэк-функцию при статусе 401.

              Можно реализовать на стандартных библиотеках, навесив обработчики на события ajaxError, ajaxComplete

                0
                Это повлияет на уже существующий код, если там где-то используется хендлер на события error или complete, мне нельзя пустить их выполнятся, если нужна аутентификация
            +1
            Если разавторизация по таймауту — обязательна, то ваше решение просто сведет это требование на нет. Так человек при простое разлогинивается, а в вашем случае будет висеть открытое окошко бесконечно долго. А предложенное решение и проблему с повторным входом и сохраняет состояние.
              0
              При такой постановке задачи не могу с Вами не согласится. Вы совершенно правы!
              Но автор упоминает статусы ответа сервера 401 и 403, это работает для HTTP-аутентификации. На большинстве же сайтов будет ответ 200, и авторизацию пользователя нужно проверять дополнительно.

              Я обычно на стороне бек-энда проверяю авторизацию и при необходимости отдаю статус о необходимости авторизации, так как на статусе ответа не мог решить вопрос.
                0
                А при чём тут вообще авторизация? Речь идёт об аутентификации. 401 тут вполне уместен, а вот 403 нет.
                0
                Да, текущее локальное состояние.
                А теперь немножко дегтя.
                Вы открыли страницу, произвели некоторые действия, например, нажали удаление какого-то элемента. Но, пропал WiFi, действие не выполнилось. Вы закрыли ноут, он ушел в Hibernate-состояние. На работе вы открыли эту страницу и повторно выполнили действие. Потом включили ноут, залогинились, и… скрипт автоматически должен выполнить какие-то отложенные действия, потому что так задумано. Хорошо, если они не имеют никакого негативного влияния, и спокойно могут отработаться на стороне сервера, но вы можете попасть в нелицеприятную ситуацию, когда, например, кому-то отправится письмо, в котором написано немного не то, что вы написали в адекватном состоянии позже.

                Таким образом, решая одну проблему, мы сталкиваемся с другой — после логина нужно от сервера получать некий хэш состояния, который сравнивать со своим, чтобы убедиться, что за это время на сервере ничего не изменилось. В случае несовпадения состояний, нужно реинициализировать окружение.
                  +1
                  Этим занимается конечный код, который делает сами запросы и меняет содержимое страницы в зависимости от ответа. Или я что-то не так понял?
                    0
                    Сервер должен проверять не удален ли уже элемент, не проголосовал ли заново пользователь и тому подобное. Не было ли ранее отправлено точно такое же письмо и так далее.
                    Не вижу никакой проблемы.
                      0
                      Ну раз не видите, то вот вам задача поразмышлять

                      POST #1
                      text = «Уважаемые резиновые изделия №2!»

                      POST #2
                      text = «Дорогой коллектив!»

                      Это одно и то же письмо?

                      Чтобы вам отличать повторы запросов, вам нужно их всех хранить. Иначе, глядя только на данные, этого понять невозможно.
                        +1
                        Это разные письма. И оба должны быть отправлены. Вы же отправляли и то и другое письмо, верно?
                          0
                          Это одно и то же письмо, с точки зрения клиента. Просто вторая версия ушла перед первой автоматически.
                  0
                  Не всегда нужно продлевать. Иногда нужно наоборот — блокировать пользователя через пять минут неиспользования и требовать пароль.
                  Автор всё правильно сделал — пользователь надёжно блокируется сервером, состояние приложения не теряется. У нас ещё polling раз в минуту ходит на сервер и проверяет, не заблокирован ли пользователь.
                    0
                    Состояние приложения имеет свойство устаревать…
                    0
                    +1 поддержка сессии должна быть реализована на бекэнде, то есть если идут запросы, то значит сессия еще жива и нет смысла ее обрывать, а вот если последний запрос давно не поступал… то тут уже от задач проекта зависит.
                    –3
                    Если у вас была проблема, и вы придумали решить ее с помощью regexp'а ajax'а, то теперь у вас две проблемы.
                    (с) народная программистская мудрость
                      +1
                      Если ajax для вас — проблема, вы — некомпетентны, и в it вам делать нечего.
                      (с) очевидная капитанская мудрость
                        –4
                        Да, мне явно делать нечего в IT и я очень некомпетентен.

                        И это не смотря на то, что уже как 5 лет работают SaaS-сервисы для корпоративного сектора, построенные мной полностью на AJAH, AJAX (был, пришлось отказаться), и даже AJ. Но кому это интересно?

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

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

                      +3
                      api.jquery.com/ajaxComplete/
                      $(document).ajaxComplete(function(e,xhr){
                      	if (xhr.status == "403") {
                      	    location.href="/error/deny";
                      	}
                      });
                      


                      или

                      api.jquery.com/jQuery.ajaxSetup/ (если нужно иметь возможность перегрузить на местах)
                      $.ajaxSetup({
                          complete: function(jqXHR, textStatus, errorThrown) {
                              if (jqXHR.status == "403") {
                                  location.href="/error/deny";
                              }
                          }
                      });
                      

                        0
                          +1
                          Ох, ну говорю же, не должны выполнятся навешанные конечным кодом, если нужна переаутентификация
                            +1
                            *конечным кодом ивенты
                              0
                              Если честно, то это проблема нетранзакционной логики построения общения клиента и сервера.

                              Я не говорю, что это плохо, что реализация неудачная. Нет, все хорошо, код работал, опыт накапливался. Но это больше «синхронное» мышление, потому что вы на вызов хотите сразу получить ответ. Чтобы от этого избавиться, вам все равно придется рефакторить код и переходить к транзакционному мышлению

                              Что есть транзакция применительно к данному случаю? Давайте рассмотрим логику работы некого модуля транзакций

                              1. Сформировать пакет данных для запроса и записать как транзакцию в очереди
                              2. Вызвать обработчик очереди.
                              3. Если транзакция прошла, то выполняем правильный колбэк и удаляем ее из очереди
                              4. Если не прошла с кодом 401, то выполняем колбэк аутентификации.
                              5. Если не прошла с кодом 409 (conflict), то нужно просто отменить транзакцию

                              Основное отличие в том, что вы никогда не вызываете напрямую send-методы, а используете только модуль транзакций. Хранение данных в очереди позволит вам решить еще одну проблему — флуд запросов. Например, пользователи часто любят два раза нажимать на кнопку, и тогда на сервер уходят два запроса.
                                0
                                Это интересно, я никогда ничего подобного не слышал в контексте джаваскрипта (но мне много аналогий приходит связанных с Doctrine2, UnitOfWork особенно). А есть какие-то готовые решения, которые так работают?

                                Единственное, что это немного перегруженная логика, точнее не логика, логика то как раз довольно прозрачная, но реализация, я чувствую, жесть будет.
                                  0
                                  Скорее всего есть. Вопрос только — где :)

                                  В коде особо менять ничего не нужно, запросы как посылались путем TransactionManager.send( dataObj, okHandler, errHandler ), так и будут посылаться. Что изменяется — так это способ обработки ответа.

                                  Вы зря пугаетесь, нужно просто правильно все в голове уложить, реализация там копеечная.
                                  0
                                  И еще проблема — юзер хочет все сразу, по веянию его курсора. А вы, я так понимаю, предлагаете собирать бэтчи, и по количеству, либо таймауту, коммитить их?
                                    0
                                    Все будет и дальше работать по веянию курсора.

                                    Давайте поясню немного детальнее.

                                    После того, как была вызвана функция send(), вы должны отскладировать ее параметры где-то во внутреннем массиве запросов transactionList = [ transaction1 ].

                                    Дальше выполняется не функция отсылки запроса, а функция проверки общего состояния системы. Что она должна делать? Пытаемся выполнить первую транзакцию из списка transactionList[0]. Если в ответ прислали 409, то очищаем массив транзакций transactionList = [] и выполняем обработчик перезагрузки состояний (скорее всего интерфейса целиком).

                                    Если у нас 401, то выполняем обработчик аутентификации, который вставит запрос аутентификации перед всей очередью и снова же выполнит функцию отсылки запроса. [ authTransaction, transaction1 ].

                                    Если первая транзакция выполнена со статусом 200, то выполняем вторую, третью и так далее.

                                    Если в массиве транзакций уже есть похожая, то мы туда ее не добавляем. Ничего сложного
                              0
                              Ну вообще-то нефиг юзать XMLHttpRequest напрямую. Если его создание у вас было бы в какой-то фабрике (или вы пользовались бы какими-то обёртками вокруг него), то вопрос и яйца выеденного не стоит — подменили реализацию и всё.
                                0
                                Конечно используется jquery, только его исходники менять я не хотел (ну не хорошо это же), а использовать его событие ajaxError не возможно было (т.к. влияет на естественный ход исполнения кода, использующего $.ajax с событиями error/complete)
                                +4
                                Какой-то кошмарный костыль.

                                1. При активном использовании ajax, даже jquery-метод можно обернуть в какой-то свой, чтобы абсолютно все запросы проходили через него — но это делается изначально.

                                2. Если уже есть код с кучей ajax-вызовов в разных местах — проще и правильнее убрать всю
                                эту копипасту и отрефакторить к варианту 1.

                                3. Если лень рефакторить — можно подменить\расширить метод jQuery.ajax, либо создать новый jQuery.myAjax и автозаменой пройтись по всему проекту.

                                Патчить дефолтные «классы»\«объекты» как-то всё нутро противиться :)
                                  0
                                  Если и идти таким путём, то улучшить текущий вариант можно в направлении большей независимости:

                                  * отделить реализацию wrapper`а от внутренних событий, создав для них события
                                  * все интерфейсные и прочие реаутентификационные штуки уже в событиях, вытащенных наружу прописать

                                  В итоге это решение превратится в компонент — щас это хардкод конкретной задачи, который на другой проект никак не переходит без переписи внутреннего мяса.

                                  PS: И конечно тестирование :) Уже в опере там какие-то проблемы. Именно поэтому товарищи выше не рекомендуют корные компоненты изобретать. Не потому, что это невозможно, а потому, что затраты на тестирование будут гораздо выше, чем на разработку.
                                    0
                                    А вы везде использовали XMLHttpRequest без обёртки? :)

                                    Мягко говоря, это странно, если ваш проект не ограничивается двумя-тремя ajax запросами.
                                      0
                                      Мягко говоря, это странно, если ваш проект не ограничивается двумя-тремя ajax запросами.

                                      Не понял что вы этим хотели сказать.
                                      +2
                                      Прошу прощения за видимый само-пиар, полная обертка: github.com/ilinsky/xmlhttprequest с фиксацией большинства багов старых и новых браузеров: www.ilinsky.com/articles/XMLHttpRequest/ (memory leaks, status change ordering etc.) Используется в многих проектах с 2007 года.
                                        0
                                        Вот как не гуглил, не нашел :)
                                        Отписал в посте

                                      Only users with full accounts can post comments. Log in, please.