領先一步
VMware 提供培訓和認證,助您加速進步。
瞭解更多在本文中,我們將繼續 討論 如何在“單頁應用程式”中使用 Spring Security 和 Angular JS。在這裡,我們將展示如何模組化客戶端程式碼,以及如何使用“漂亮”的 URL 路徑,而不是 Angular 預設使用的、但大多數使用者不喜歡的片段符號(例如“/#/login”)。這是系列文章的第七篇,你可以透過閱讀 第一篇文章 來了解應用程式的基本構建塊或從頭開始構建它,或者直接前往 Github 上的原始碼。我們將能夠整理本系列剩餘 JavaScript 程式碼中的許多遺留問題,同時展示它如何能與基於 Spring Security 和 Spring Boot 構建的後端伺服器完美契合。
到目前為止,本系列中我們使用的示例應用程式過於簡單,以至於我們可以將整個應用程式的 JavaScript 程式碼放在一個原始檔中。沒有哪個大型應用程式會是這樣的,即使它一開始是這樣開始的,所以為了在示例中模仿現實生活,我們將把它們拆分。一個好的起點是採用《第二部分》中的“單檔案”應用程式,並在原始碼中檢視其結構。下面是靜態內容(不包括屬於伺服器的“application.yml”)的目錄列表:
static/
js/
hello.js
home.html
login.html
index.html
這其中有幾個問題。一個顯而易見:所有的 JavaScript 都在一個檔案中(hello.js)。另一個更微妙:我們有應用程式內的 HTML “部分”檢視(“login.html”和“home.html”),但它們都處於平坦的結構中,並且與使用它們 的控制器程式碼沒有關聯。
讓我們仔細看看 JavaScript,我們會發現 Angular 很容易讓我們將其拆分成更易於管理的塊。
angular.module('hello', [ 'ngRoute' ]).config(
function($routeProvider, $httpProvider) {
$routeProvider.when('/', {
templateUrl : 'home.html',
controller : 'home'
}).when('/login', {
templateUrl : 'login.html',
controller : 'navigation'
}).otherwise('/');
...
}).controller('navigation',
function($rootScope, $scope, $http, $location, $route) {
...
}).controller('home', function($scope, $http) {
...
})
});
有一些“配置”和 2 個控制器(“home”和“navigation”),控制器似乎與它們對應的“部分”檢視(分別為“home.html”和“login.html”)很好地匹配。所以讓我們將它們拆分成這些部分。
static/
js/
home/
home.js
home.html
navigation/
navigation.js
login.html
hello.js
index.html
控制器定義已移至它們自己的模組中,以及它們需要操作的 HTML——漂亮且模組化。如果我們需要影像或自定義樣式表,我們也會對它們做同樣的事情。
注意:所有客戶端程式碼都位於單個目錄“js”下(除了
index.html,因為它是一個“歡迎”頁面,並且會自動從“static”目錄載入)。這是故意的,因為它使得對所有靜態資源應用單個 Spring Security 訪問規則變得容易。這些資源都是不安全的(因為在 Spring Boot 應用程式中,/js/**預設是不安全的),但對於其他應用程式,你可能需要其他規則,在這種情況下,你可以選擇不同的路徑。
例如,這是 home.js
angular.module('home', []).controller('home', function($scope, $http) {
$http.get('/user/').success(function(data) {
$scope.user = data.name;
});
});
這是新的 hello.js
angular
.module('hello', [ 'ngRoute', 'home', 'navigation' ])
.config(
function($routeProvider, $httpProvider) {
$routeProvider.when('/', {
templateUrl : 'js/home/home.html',
controller : 'home'
}).when('/login', {
templateUrl : 'js/navigation/login.html',
controller : 'navigation'
}).otherwise('/');
$httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
});
請注意,“hello”模組是如何透過在初始宣告中列出它們和 ngRoute 來“依賴”其他兩個模組的。要使其正常工作,你只需在 index.html 中按正確的順序載入模組定義即可。
...
<script src="js/angular-bootstrap.js" type="text/javascript"></script>
<script src="js/home/home.js" type="text/javascript"></script>
<script src="js/navigation/navigation.js" type="text/javascript"></script>
<script src="js/hello.js" type="text/javascript"></script>
...
這就是 Angular JS 依賴管理系統的作用。其他框架也有類似(甚至可以說更優越)的功能。此外,在一個更大的應用程式中,你可能會使用構建步驟將所有 JavaScript 打包在一起,以便瀏覽器可以高效地載入它們,但這幾乎是口味問題。
Angular 的 $routeProvider 預設使用 URL 路徑中的片段定位符,例如,登入頁面在 hello.js 中被宣告為路由“/login”,這會轉化為瀏覽器 URL 中的“/#/login”。這是為了確保透過根路徑“/”載入的 index.html 中的 JavaScript 在所有路由上保持活動狀態。片段命名對使用者來說有些陌生,使用“自然”路由有時更方便,即 URL 路徑與 Angular 路由宣告相同,例如“/login”對應“/login”。如果你只有靜態資源,這是不可能的,因為 index.html 只能以一種方式載入。但如果你有棧中一些活動的元件(代理或伺服器端邏輯),那麼你就可以透過從所有 Angular 路由載入 index.html 來實現這一點。
在系列文章中,你使用了 Spring Boot,所以當然有伺服器端邏輯,並且可以使用簡單的 Spring MVC 控制器來使應用程式中的路由自然化。你只需要一種方法來列舉伺服器上的 Angular 路由。在這裡,我們選擇透過命名約定來做到這一點:所有不包含點的路徑(並且尚未顯式對映)都是 Angular 路由,應該轉發到主頁。
@RequestMapping(value = "/{[path:[^\\.]*}")
public String redirect() {
return "forward:/";
}
這個方法只需要放在 Spring 應用程式中的某個 @Controller(而不是 @RestController)中。我們使用“forward”(而不是“redirect”),這樣瀏覽器就會記住“真實”路由,並且使用者會在 URL 中看到它。這也意味著 Spring Security 中任何關於身份驗證的已儲存請求機制都可以開箱即用,儘管我們在此應用程式中不會利用這一點。
注意:Github 上的示例程式碼 應用程式 有一個額外的路由,因此你可以看到一個功能更全面、因此更真實的應用程式(“/home”和“/message”是不同的模組,具有略微不同的檢視)。
要使用“自然”路由完成應用程式,你需要告知 Angular。有兩步。首先,在 hello.js 中,你在 config 函式中新增一行,在 $locationProvider 中設定“HTML5 模式”。
angular.module('hello', [ 'ngRoute', 'home', 'navigation' ]).config(
function($locationProvider, $routeProvider, $httpProvider) {
$locationProvider.html5Mode(true);
...
});
除此之外,你需要在 index.html 的頭部新增一個額外的 <base/> 元素,並修改選單欄中的連結以刪除片段(“#”)
<html>
<head>
<base href="/" />
...
</head>
<body ng-app="hello" ng-cloak class="ng-cloak">
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
<li><a href="/">home</a></li>
<li><a href="/login">login</a></li>
<li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
</ul>
</div>
...
</html>
Angular 使用 <base/> 元素來錨定路由並寫入瀏覽器中顯示的 URL。你正在執行一個 Spring Boot 應用程式,因此預設設定是從根路徑“/”(在 8080 埠)提供服務。如果你需要能夠從同一應用程式的不同根路徑提供服務,那麼你需要使用伺服器端模板將該路徑渲染到 HTML 中(許多人更喜歡為單頁應用程式保留靜態資源,所以他們只能使用靜態根路徑)。
當你上面模組化應用程式時,你應該發現程式碼只需將其拆分成模組就可以工作,但其中有一個小瑕疵,我們仍然使用 $rootScope 在控制器之間共享狀態。對於如此小的應用程式來說,這並沒有什麼大問題,它讓我們很快就得到了一個不錯的原型來玩,所以我們不必對此感到太遺憾,但現在我們可以藉此機會將所有身份驗證 concerns 提取到一個單獨的模組中。在 Angular 術語中,你需要的是一個“服務”,所以建立一個與你的“home”和“navigation”模組並列的新模組(“auth”)。
static/
js/
auth/
auth.js
home/
home.js
home.html
navigation/
navigation.js
login.html
hello.js
index.html
在編寫 auth.js 程式碼之前,我們可以預測其他模組的變化。首先,在 navigation.js 中,你應該讓“navigation”模組依賴於新的“auth”模組,並將“auth”服務注入到控制器中(當然,$rootScope 不再需要了)。
angular.module('navigation', ['auth']).controller(
'navigation',
function($scope, auth) {
$scope.credentials = {};
$scope.authenticated = function() {
return auth.authenticated;
}
$scope.login = function() {
auth.authenticate($scope.credentials, function(authenticated) {
if (authenticated) {
console.log("Login succeeded")
$scope.error = false;
} else {
console.log("Login failed")
$scope.error = true;
}
})
};
$scope.logout = function() {
auth.clear();
}
});
它與舊控制器並沒有太大區別(它仍然需要使用者操作、登入和登出的函式,以及一個物件來儲存登入憑據),但它將實現抽象到了新的“auth”服務中。“auth”服務將需要一個 authenticate() 函式來支援 login(),以及一個 clear() 函式來支援 logout()。它還有一個 authenticated 標誌,取代了舊控制器中的 $rootScope.authenticated。我們在附加到控制器 $scope 的同名函式中使用 authenticated 標誌,這樣 Angular 就會不斷檢查其值並在使用者登入時更新 UI。
假設你想讓“auth”模組可重用,所以你不希望其中有任何硬編碼的路徑。這沒問題,但你需要在 hello.js 模組中初始化或配置路徑,所以你可以新增一個 run() 函式。
angular
.module('hello', [ 'ngRoute', 'auth', 'home', 'navigation' ])
.config(
...
}).run(function(auth) {
auth.init('/', '/login', '/logout');
});
run() 函式可以呼叫“hello”依賴的任何模組,在本例中注入一個 auth 服務,並用主頁、登入和登出端點的路徑來初始化它。
然後,你需要在 index.html 中載入“auth”模組,除了其他模組(並且在“login”模組之前,因為它依賴於“auth”)
...
<script src="js/auth/auth.js" type="text/javascript"></script>
...
<script src="js/hello.js" type="text/javascript"></script>
...
最後,你可以編寫上面草擬的三個函式的程式碼(authenticate()、clear() 和 init())。這是大部分程式碼。
angular.module('auth', []).factory(
'auth',
function($http, $location) {
var auth = {
authenticated : false,
loginPath : '/login',
logoutPath : '/logout',
homePath : '/',
authenticate : function(credentials, callback) {
var headers = credentials && credentials.username ? {
authorization : "Basic "
+ btoa(credentials.username + ":"
+ credentials.password)
} : {};
$http.get('user', {
headers : headers
}).success(function(data) {
if (data.name) {
auth.authenticated = true;
} else {
auth.authenticated = false;
}
$location.path(auth.homePath);
callback && callback(auth.authenticated);
}).error(function() {
auth.authenticated = false;
callback && callback(false);
});
},
clear : function() { ... },
init : function(homePath, loginPath, logoutPath) { ... }
};
return auth;
});
“auth”模組為 auth 服務(例如,你已經將其注入了“navigation”控制器)建立一個工廠。工廠只是一個返回物件的函式(auth),該物件必須具有上面預期的三個函式和標誌。上面,我們展示了 authenticate() 函式的實現,它與“navigation”控制器中的舊函式基本相同,它呼叫後端資源“/user”,設定 authenticated 標誌,並使用標誌的值呼叫一個可選的回撥。如果成功,它還會使用 $location 服務將使用者轉到 homePath(我們稍後會改進這一點)。
這是 init() 函式的一個基本實現,它只是設定了你不想硬編碼在“auth”模組中的各種路徑。
init : function(homePath, loginPath, logoutPath) {
auth.homePath = homePath;
auth.loginPath = loginPath;
auth.logoutPath = logoutPath;
}
接下來是 clear() 函式的實現,但它相當簡單。
clear : function() {
auth.authenticated = false;
$location.path(auth.loginPath);
$http.post(auth.logoutPath, {});
}
它取消設定 authenticated 標誌,將使用者重定向到登入頁面,然後向登出路徑傳送一個 HTTP POST 請求。POST 請求成功,因為我們仍然保留了原始“單檔案”應用程式的 CSRF 保護功能。如果你看到 403,請檢視錯誤訊息和伺服器日誌,然後檢查你是否已設定該過濾器以及是否正在傳送 XSRF cookie。
最後一步是修改 index.html,以便在使用者未認證時隱藏“登出”連結。
<html>
...
<body ng-app="hello" ng-cloak class="ng-cloak">
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
...
<li ng-show="authenticated()"><a href="" ng-click="logout()">logout</a></li>
</ul>
</div>
...
</html>
你只需要將標誌 authenticated 轉換為函式呼叫 authenticated(),這樣“navigation”控制器就可以訪問“auth”服務,並在標誌不再位於 $rootScope 中時找到其值。
到目前為止,我們實現主頁的方式是,當用戶未認證時,它會顯示一些內容(它只是邀請他們登入)。有些應用程式是這樣工作的,有些則不是。有些提供不同的使用者體驗,即使用者在認證之前從未看到任何東西,只看到登入頁面,所以讓我們看看如何將我們的應用程式轉換為這種模式。
隱藏所有內容並只顯示登入頁面是一個經典的橫切關注點:你不希望將所有顯示登入頁面的邏輯都塞到所有 UI 模組中(它會在各處重複,使程式碼難以閱讀和維護)。Spring Security 完全是關於伺服器中的橫切關注點,因為它構建在 Filters 和 AOP 攔截器之上。不幸的是,這在單頁應用程式中對我們幫助不大,但幸運的是,Angular 也有一些功能,可以讓我們輕鬆實現所需的模式。這裡的有用功能是你可以安裝一個“路由更改”監聽器,所以每次使用者移動到新路由(即點選選單欄或其他內容)或頁面首次載入時,你都可以檢查該路由,並在需要時更改它。
要安裝監聽器,你可以在 auth.init() 函式中編寫少量額外的程式碼(因為該函式已經安排在主“hello”模組載入時執行)。
angular.module('auth', []).factory(
'auth',
function($rootScope, $http, $location) {
var auth = {
...
init : function(homePath, loginPath, logoutPath) {
...
$rootScope.$on('$routeChangeStart', function() {
enter();
});
}
};
return auth;
});
我們註冊了一個簡單的監聽器,它只是委託給一個新的 enter() 函式,所以現在你還需要在“auth”模組的工廠函式(它在那裡可以訪問工廠物件本身)中實現它。
enter = function() {
if ($location.path() != auth.loginPath) {
auth.path = $location.path();
if (!auth.authenticated) {
$location.path(auth.loginPath);
}
}
}
邏輯很簡單:如果路徑剛剛更改為非登入頁面,則記錄該路徑值,然後如果使用者未認證,則轉到登入頁面。我們儲存路徑值的原因是為了在成功認證後可以返回到它(Spring Security 在伺服器端有此功能,對使用者來說非常方便)。你可以在 authenticate() 函式中透過在成功處理程式中新增一些程式碼來實現這一點。
authenticate : function(credentials, callback) {
...
$http.get('user', {
headers : headers
}).success(function(data) {
...
$location.path(auth.path==auth.loginPath ? auth.homePath : auth.path);
}).error(...);
},
成功認證後,我們將位置設定為主頁或最近選擇的路徑(只要它不是登入頁面)。
還有一個最終的更改,以使使用者體驗更加統一:我們希望在應用程式首次啟動時顯示登入頁面而不是主頁。你已經在 authenticate() 函式中有了該邏輯(重定向到登入頁面),所以你只需要在 init() 函式中新增一些程式碼來使用空憑據進行身份驗證(除非使用者已有 cookie,否則會失敗)。
init : function(homePath, loginPath, logoutPath) {
...
auth.authenticate({}, function(authenticated) {
if (authenticated) {
$location.path(auth.path);
}
});
...
}
只要 auth.path 使用 $location.path() 初始化,即使使用者在瀏覽器中顯式輸入路由(即不想先載入主頁),這也將起作用。
啟動應用程式(使用你的 IDE 和 main() 方法,或在命令列中使用 mvn spring-boot:run),並在 https://:8080 訪問它以檢視結果。
提醒:請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,最好的方法是開啟一個新的無痕視窗。
在本文中,我們學習瞭如何模組化 Angular 應用程式(以系列文章 《第二部分》 中的應用程式為起點)、如何使其重定向到登入頁面,以及如何使用使用者可以輕鬆輸入或收藏夾的“自然”路由。我們退後一步,回顧了系列文章的最後幾篇,更多地關注了客戶端程式碼,並暫時放棄了我們在第三至第六部分中構建的分散式架構。這並不意味著這裡的更改不能應用於那些其他應用程式(實際上它相當簡單),這只是為了在我們學習客戶端如何工作時簡化伺服器端程式碼。不過,我們確實使用或簡要討論了幾個伺服器端功能(例如,在 Spring MVC 中使用“forward”檢視來實現“自然”路由),因此我們繼續了 Angular 和 Spring 協同工作的這一主題,並表明它們透過這裡那裡的小調整可以很好地協同工作。
系列中的 下一部分 是關於測試客戶端程式碼。