領先一步
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“partials”(“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”),並且這些控制器似乎很好地對映到相應的 partials(分別為“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”路由,URL 也顯示為“/login”。如果只有靜態資源,您無法做到這一點,因為 index.html
只能透過一種方式載入,但如果您在技術棧中有一些活躍的元件(代理或一些伺服器端邏輯),則可以透過從所有 Angular 路由載入 index.html
來使其工作。
在本系列中,您使用的是 Spring Boot,因此自然會有伺服器端邏輯,使用一個簡單的 Spring MVC 控制器,您可以使應用程式中的路由“自然化”。您只需要一種在伺服器中列舉 Angular 路由的方法。在這裡,我們選擇透過命名約定來實現:所有不包含點(並且尚未明確對映)的路徑都是 Angular 路由,應該轉發到主頁:
@RequestMapping(value = "/{[path:[^\\.]*}")
public String redirect() {
return "forward:/";
}
這個方法只需放在 Spring 應用程式中的某個 @Controller
(而不是 @RestController
)中。我們使用“轉發”(而不是“重定向”),這樣瀏覽器會記住“真實”的路由,使用者在 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
的 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
在控制器之間共享狀態。對於這樣一個小型應用程式來說,這樣做沒什麼大問題,並且它讓我們很快就得到了一個不錯的原型進行嘗試,所以我們不必為此感到太難過,但現在我們可以藉此機會將所有認證相關功能提取到一個單獨的模組中。在 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 基本認證憑據快取。在 Chrome 中,最好的方法是開啟一個新無痕視窗。
在本文中,我們探討了如何模組化一個 Angular 應用程式(以系列文章的第二部分中的應用程式為起點),如何使其重定向到登入頁面,以及如何使用使用者可以輕鬆輸入或書籤的“自然”路由。我們回顧了本系列的最後幾篇文章,更專注於客戶端程式碼,並暫時放棄了我們在第三到第六部分中構建的分散式架構。但這並不意味著這裡的更改無法應用於其他應用程式(實際上非常簡單)——我們只是為了在學習客戶端操作時簡化伺服器端程式碼。儘管如此,我們確實使用或簡要討論了一些伺服器端功能(例如在 Spring MVC 中使用“forward”檢視來實現“自然”路由),因此我們繼續了 Angular 和 Spring 協同工作的主題,並展示了它們透過一些小的調整就可以很好地配合。
系列下一部分將介紹如何測試客戶端程式碼。