领先一步
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 协同工作的这一主题,并表明它们通过这里那里的小调整可以很好地协同工作。
系列中的 下一部分 是关于测试客户端代码。