3 November 2013

Вариант условного роутинга в AngularJS

Angular
Я новичок в AngularJS, совсем недавно решил его использовать в своем хобби-проекте. Довольно быстро передо мной встала задача настроить роутинг по определенным условиям, самое простое и очевидное из таких условий — авторизован пользователь или нет. Приложение содержит страницы, открытые любому пользователю, и страницы, куда можно зайти только авторизовавшись. Если авторизации нет, нужно попробовать ее получить прозрачно для пользователя, если это не удалось — средиректить на страницу логина.

На сколько я понимаю, это задача достаточно распространенная, тем не менее я не нашел простого готового способа сделать это из коробки. Потратив изрядное количество времени на гугление, чтение документации и эксперименты, я в итоге нашел достаточно элегантное решение для моего случая. Спешу поделиться своим велосипедом, надеюсь, это поможет сэкономить время таким же начинающим пользователям AngularJS. Возможно, найдется гуру, который укажет на то самое стандартное решение, которое я почему-то не нашел. Я, например, еще не разбирался с ui-router.

Задача


Стоит чуть подробнее написать про мою задачу. Имеется одностраничное веб-приложение (Single Page Application). Для простоты будем считать, что есть одна общедоступная «главная» страница по пути "/", то есть в корне сайта. На ней пользователь может пройти регистрацию или залогиниться. При успешном логине, приложение получает авторизационный токен и пользовательский профиль. Профиль представляет собой развесистую структуру данных и, для снижения нагрузки на сервер, я хочу загружать его один раз при логине, а не по частям на каждой странице. Авторизационный токен может храниться достаточно долго в локальном хранилище браузера, но данные профиля загружаются заново при каждом обновлении страницы. Получив токен один раз, я могу свободно ходить по всем закрытым страницам, перезагружать любую из них, добавлять в закладки и т.д. профиль при этом подгружается прозрачно для меня. Но если токен протухнет или я дам ссылку на закрытую страницу сайта другу, сайт должен отправить меня (или друга) на главную страницу.

Поиски решения


Единственное полезное, что выдал гугл на запрос «Angularjs conditional routing» — вот этот вопрос на stackoverflow. Там предлагалось два решения:

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

Второе — перехватывать сообщение $routeChangeStart, проверять, куда идем, есть ли авторизация и, если нет, редиректить. Как вариант — слушать изменения пути через $scope.$watch(function() { return $location.path(); }. Недостатки этого решения:

1. В случае с $routeChangeStart объект next не предоставляет пути роутинга, понимать куда идем не очень удобно; сообщение это будет кидаться и на редиректах с несуществующих страниц, в итоге выражения в условиях роутинга будут не слишком красивые, завязаны на названия темплейтов, имена контроллеров и прочие странные вещи.
2. Если нужно подгрузить данные, как в моем случае, нет способа задержать роутинг до конца загрузки. При этом роутинг может зависеть от самих данных в профиле пользователя — к примеру, у него новое супер-предложение и нужно все бросать и срочно идти на страницу этого предложения.

У меня была мысль при отсутствующих данных редиректить на отдельную «страницу загрузки», там догружать данные и редиректить по результатам, но во-первых логика роутинга размазывается по двум местам — в одном смотрим путь, в другом данные; во-вторых у пользователя в истории останется эта промежуточная страница. Историю можно затирать с помощью $location.replace(), но если загрузка по каким-то причинам задержится и пользователь успеет нажать Назад, затрется не та страница, а его еще и средиректит уже с другой страницы, нужно как-то обрабатывать еще и этот случай, что простоты решению не добавляет. В-третьих нужно где-то запоминать, куда мы шли, чтобы правильно средиректить, с учетом ситуации из «во-вторых». Это решение меня не вдохновило и я продолжил поиски.

Решение


AngularJS предоставляет сервис со интересным названием $q. В документации можно прочитать почему q и спецификацию defered/promise — достаточно простая и интересная концепция. Вкратце, мы просим сервис изготовить специальный объект

var defered = $q.defer();


Из этого объекта получаем объект-promise и отдаем клиенту нашего кода

return defered.promise;


Клиент вешает на promise коллбэки успеха и неудачи операции

promise.then(function (result) {...}, function (reason) {...});


Теперь когда мы мы сделаем на нашем объекте

defered.resolve(result);


или

defered.reject();


у клиента вызовется соответствующий коллбэк. Чем это лучше обычных коллбэков? promise'ы можно объединять в цепочки (за подробностями — в документацию) и, что важно для моей задачи, многие сервисы AngularJS умеют работать с ними, в том числе в $routerProvider в конфигурации роута можно указать поле resolve и передать туда функцию, возвращающую promise. При этом если эта функция вернет объект, который не является promise, он будет интерпретирован как уже зарезолвленный promise. Роут будет ждать, пока promise не зарезолвится, а если произойдет reject, то и вовсе отменится. Дальше все просто — пишем функцию, которая загружает данные, если нужно, делает все проверки и редиректы. Если нужно подгрузить данные — возвращается promise, если нужно сделать редирект — перед ним promise реджектится, чтобы старый роут не ждал зря.

Код решения:

'use strict';

var app = angular.module('app', [])
    .config(['$routeProvider', function($routeProvider) {
        $routeProvider
            .when('/', {
                templateUrl: "login.html",
                controller: LoginController
            })
            .when('/private', {
                templateUrl: "private.html",
                controller: PrivateController,
                resolve: {
                    factory: checkRouting
                }
            })
            .when('/private/anotherpage', {
                templateUrl:"another-private.html",
                controller: AnotherPriveController,
                resolve: {
                    factory: checkRouting
                }
            })
            .otherwise({ redirectTo: '/' });
    }]);

var checkRouting= function ($q, $rootScope, $location) {
    if ($rootScope.userProfile) {
        return true;
    } else {
        var defered = $q.defer();
        $http.post("/loadUserProfile", { userToken: "blah" })
            .success(function (response) {
                $rootScope.userProfile = response.userProfile;
                defered.resolve(true);
            })
            .error(function () {
                defered.reject();
                $location.path("/");
             });
        return defered.promise;
    }
};


В итоге получилось достаточно просто и прозрачно, даже странно почему я такого сразу не нашел в сети (теперь, надеюсь, найти будет проще). Из недостатков можно отметить то, что в каждом роуте нужно прописывать resolve, но c другой стороны это дает явность конфигурации и гибкость — можно написать еще пару таких же check* функций (если логика для разных страниц совершенно разная) и использовать, где нужно.

UPDATE: Комментарии сподвигли меня на написание кода с передачей обещаний по цепочке от $http.post(), который, как и другие методы сервиса $http, возвращает обещание. При таком, натуральном, использовании обещаний получаем классное четкое разделение процесса по этапам и функции с ясным контрактом.

Механизм цепочек обещаний такой: метод then обещания возвращает другое «производное» обещание, которое зарезолвится с тем значением, которое вернет один из обработчиков, указанных в then — резолва или реджекта — или зареджектится в случае, если один из обработчиков выбросит исключение. При этом если возвращаемое значение — само обещание, то его результат определит результат производного обещания. Так, чтобы зареджектить производное обещание, достаточно вернуть $q.reject().

В итоге решение выглядит так:

// Возвращает значение или обещание, определяющее результат resolve роутинга.
var checkRouting = function ($q, $rootScope, $http, $location, localStorageService) {
    // Утилитная функция для редиректа
    function redirect(path) {
        if ($location.path() != path) {
            $location.path(path); // Делаем редирект.
            return $q.reject(); // Отменит старый роутинг.
        } else {
            return true; // Позволит роутингу случиться.
        }
    }

    return getUserDataPromise($q, $rootScope, $http, localStorageService)
        .then(function (userData) {
            // Здесь в единственном месте проверяем данные пользователя.
            if (userData.sales.lenght > 0) {
                return redirect("/sales"); // Пока есть распродажа, надо идти туда!
            } else {
                return true; // Иначе идем, куда шли.
            }
        }, function (reason) {
            // Здесь в единственном месте обрабатываем ошибки.
            console.error(reason); // Хотя бы просто логируем ; )
            return redirect("/");
        });
};

// Вернет обещание, которое либо зарезолвится с готовым userData, либо зареджектится.
var getUserDataPromise = function ($q, $rootScope, $http, localStorageService) {
    if ($rootScope.userData) {
        return $q.when($rootScope.userData); // Оборачиваем значение в (уже зарезолвленное) обещание.
    } else {
        var userToken = localStorageService.get("userToken");
        if (!userToken) {
            return $q.reject("No user token."); // Следующее обещание в цепочке зареджектится.
        } else {
            // Возвращаем обещание, которое либо заролвится с данными пользователя с сервера,
            // либо зареджектится, если не удалось их загрузить.
            return $http.post("/loadUserData", { userToken: userToken })
                .then(function (result) {
                    if (result.data.userData) {
                        $rootScope.userData = result.data.userData;
                        return result.data.userData;
                    } else {
                        return $q.reject("Got response from server but without user data.");
                    }
                }, function (reason) {
                    return $q.reject("Error requesting server: " + reason);
                });
        }
    }
};

Tags:angularjs
Hubs: Angular
+14
22.9k 106
Comments 18