Angular6 面向企业级的 Web 开发(三)
原文:
zh.annas-archive.org/md5/87CFF2637ACB075A16B30B5AA7A68992译者:飞龙
第七章:创建一个路由优先的业务应用
业务应用(LOB)是软件开发世界的基础。根据维基百科的定义,LOB 是一个通用术语,指的是为特定客户交易或业务需求提供产品或一组相关产品。LOB 应用程序提供了展示各种功能和功能的良好机会,而无需涉及大型企业应用程序通常需要的扭曲或专业化场景。在某种意义上,它们是 80-20 的学习经验。然而,我必须指出有关 LOB 应用程序的一个奇怪之处——如果您最终构建了一个半有用的 LOB 应用程序,对它的需求将不受控制地增长,您很快就会成为自己成功的受害者。这就是为什么您应该把每个新项目的开始视为一个机会,一个编码的机会,以便更好地创建更灵活的架构。
在本章和其余章节中,我们将建立一个具有丰富功能的新应用程序,可以满足可扩展架构和工程最佳实践的 LOB 应用程序的需求,这将帮助您在有需求时快速启动并迅速扩展解决方案。我们将遵循路由优先的设计模式,依赖可重用的组件来创建一个名为 LemonMart 的杂货店 LOB。
在本章中,您将学会以下内容:
-
有效使用 CLI 创建主要的 Angular 组件和 CLI 脚手架
-
学习如何构建路由优先应用
-
品牌、自定义和材料图标
-
使用 Augury 调试复杂的应用程序
-
启用延迟加载
-
创建一个基本框架
本书提供的代码示例需要 Angular 版本 5 和 6。Angular 5 代码与 Angular 6 兼容。Angular 6 将在 LTS 中得到支持,直到 2019 年 10 月。代码存储库的最新版本可以在以下网址找到:
-
对于第 2 到 6 章,LocalCast Weather 在 Github.com/duluca/loca…
-
对于第 7 到 12 章,LemonMart 在 Github.com/duluca/lemo…
Angular 技巧表
在我们深入创建 LOB 应用程序之前,我为您提供了一个速查表,让您熟悉常见的 Angular 语法和 CLI 命令,因为在接下来的过程中,这些语法和命令将被使用,而不会明确解释它们的目的。花些时间来审查和熟悉新的 Angular 语法、主要组件、CLI 脚手架和常见管道。如果您的背景是 AngularJS,您可能会发现这个列表特别有用,因为您需要放弃一些旧的语法。
绑定
绑定,或数据绑定,指的是代码中变量与 HTML 模板或其他组件中显示或输入的值之间的自动单向或双向连接:
| 类型 | 语法 | 数据方向 |
|---|
| 插值属性
属性
类
样式 | {{expression}}``[target]="expression"``bind-target="expression" | 从数据源单向
到视图目标 |
事件 | (目标)="语句" on-目标="语句" | 从视图目标单向
到数据源 |
| 双向 | [(target)]="expression" bindon-target="expression" | 双向 |
|---|
来源:angular.io/guide/template-syntax#binding-syntax-an-overview
内置指令
指令封装了可以作为属性应用到 HTML 元素或其他组件的编码行为:
| 名称 | 语法 | 目的 |
|---|---|---|
| 结构指令 | *ngIf``*ngFor``*ngSwitch | 控制 HTML 的结构布局,以及元素是否从 DOM 中添加或移除 |
| 属性指令 | [class]``[style]``[(model)] | 监听并修改其他 HTML 元素、属性、属性和组件的行为,如 CSS 类、HTML 样式和 HTML 表单元素 |
结构指令来源:angular.io/guide/structural-directives
属性指令来源:angular.io/guide/template-syntax#built-in-attribute-directives
常见管道
管道修改了数据绑定值在 HTML 模板中的显示方式。
| 名称 | 目的 | 用法 |
|---|---|---|
| 日期 | 根据区域设置规则格式化日期 | {{date_value | date[:format]}} |
| 文本转换 | 将文本转换为大写、小写或标题大小写 | {{value | uppercase}}``{{value | lowercase}}``{{value | titlecase }} |
| 小数 | 根据区域规则,将数字格式化 | {{number | number[:digitInfo]}} |
| 百分比 | 根据区域规则,将数字格式化为百分比 | {{number | percent[:digitInfo]}} |
| 货币 | 根据区域规则,将数字格式化为带有货币代码和符号的货币 | {{number | currency[:currencyCode [:symbolDisplay[:digitInfo]]]}} |
启动命令,主要组件和 CLI 脚手架
启动命令帮助生成新项目或添加依赖项。Angular CLI 命令帮助创建主要组件,通过自动生成样板脚手架代码来轻松完成。有关完整命令列表,请访问github.com/angular/angular-cli/wiki:
| 名称 | 目的 | CLI 命令 |
|---|---|---|
| 新建 | 创建一个新的 Angular 应用程序,并初始化 git 存储库,配置好 package.json 和路由。从父文件夹运行。 | npx @angular/cli new project-name --routing |
| 更新 | 更新 Angular,RxJS 和 Angular Material 依赖项。如有必要,重写代码以保持兼容性。 | npx ng update |
| 添加材料 | 安装和配置 Angular Material 依赖项。 | npx ng add @angular/material |
| 模块 | 创建一个新的@NgModule类。使用--routing来为子模块添加路由。可选地,使用--module将新模块导入到父模块中。 | ng g module new-module |
| 组件 | 创建一个新的@Component类。使用--module来指定父模块。可选地,使用--flat来跳过目录创建,-t用于内联模板,和-s用于内联样式。 | ng g component new-component |
| 指令 | 创建一个新的@Directive类。可选地,使用--module来为给定子模块范围内的指令。 | ng g directive new-directive |
| 管道 | 创建一个新的@Pipe类。可选地,使用--module来为给定子模块范围内的管道。 | ng g pipe new-pipe |
| 服务 | 创建一个新的@Injectable类。使用--module为给定子模块提供服务。服务不会自动导入到模块中。可选地使用--flat false 在目录下创建服务。 | ng g service new-service |
| Guard | 创建一个新的@Injectable类,实现路由生命周期钩子CanActivate。使用--module为给定的子模块提供守卫。守卫不会自动导入到模块中。 | ng g guard new-guard |
| Class | 创建一个简单的类。 | ng g class new-class |
| Interface | 创建一个简单的接口。 | ng g interface new-interface |
| Enum | 创建一个简单的枚举。 | ng g enum new-enum |
为了正确地为自定义模块下列出的一些组件进行脚手架搭建,比如my-module,你可以在你打算生成的名称前面加上模块名称,例如ng g c my-module/my-new-component。Angular CLI 将正确地连接并将新组件放置在my-module文件夹下。
配置 Angular CLI 自动完成
在使用 Angular CLI 时,您将获得自动完成的体验。执行适合您的*nix环境的适当命令:
- 对于 bash shell:
$ ng completion --bash >> ~/.bashrc
$ source ~/.bashrc
- 对于 zsh shell:
$ ng completion --zsh >> ~/.zshrc
$ source ~/.zshrc
- 对于使用 git bash shell 的 Windows 用户:
$ ng completion --bash >> ~/.bash_profile
$ source ~/.bash_profile
路由器优先架构
Angular 路由器,打包在@angular/router包中,是构建单页应用程序(SPAs)的中心和关键部分,它的行为和操作方式类似于普通网站,可以使用浏览器控件或缩放或微缩放控件轻松导航。
Angular 路由器具有高级功能,如延迟加载、路由器出口、辅助路由、智能活动链接跟踪,并且可以表达为href,这使得使用 RxJS SubjectBehavior的无状态数据驱动组件的高度灵活的路由器优先应用程序架构成为可能。
大型团队可以针对单一代码库进行工作,每个团队负责一个模块的开发,而不会互相干扰,同时实现简单的持续集成。谷歌之所以选择针对数十亿行代码进行单一代码库的工作,是有很好的原因的。事后的集成非常昂贵。
小团队可以随时重新调整他们的 UI 布局,以快速响应变化,而无需重新设计他们的代码。很容易低估由于布局或导航的后期更改而浪费的时间。这样的变化对于大型团队来说更容易吸收,但对于小团队来说是一项昂贵的努力。
通过延迟加载,所有开发人员都可以从次秒级的首次有意义的绘制中受益,因为在构建时将传递给浏览器的核心用户体验文件大小保持在最低限度。模块的大小影响下载和加载速度,因为浏览器需要做的越多,用户看到应用程序的第一个屏幕就需要的时间就越长。通过定义延迟加载的模块,每个模块都可以打包为单独的文件,可以根据需要单独下载和加载。智能活动链接跟踪可以提供卓越的开发人员和用户体验,非常容易实现突出显示功能,以指示用户当前活动的选项卡或应用程序部分。辅助路由最大化了组件的重用,并帮助轻松实现复杂的状态转换。通过辅助路由,您可以仅使用单个外部模板呈现多个主视图和详细视图。您还可以控制路由在浏览器的 URL 栏中向用户显示的方式,并使用routerLink在模板中和Router.navigate在代码中组合路由,驱动复杂的场景。
为了实现一个以路由为先的实现,您需要这样做:
-
早期定义用户角色
-
设计时考虑延迟加载
-
实现一个骨架导航体验
-
围绕主要数据组件进行设计
-
执行一个解耦的组件架构
-
区分用户控件和组件
-
最大化代码重用
用户角色通常表示用户的工作职能,例如经理或数据录入专员。在技术术语中,它们可以被视为特定类别用户被允许执行的一组操作。定义用户角色有助于识别可以配置为延迟加载的子模块。毕竟,数据录入专员永远不会看到经理可以看到的大多数屏幕,那么为什么要将这些资产传递给这些用户并减慢他们的体验呢?延迟加载在创建可扩展的应用程序架构方面至关重要,不仅从应用程序的角度来看,而且从高质量和高效的开发角度来看。配置延迟加载可能会很棘手,这就是为什么及早确定骨架导航体验非常重要的原因。
识别用户将使用的主要数据组件,例如发票或人员对象,将帮助您避免过度设计您的应用程序。围绕主要数据组件进行设计将在早期确定 API 设计,并帮助定义BehaviorSubject数据锚点,以实现无状态、数据驱动的设计,确保解耦的组件架构,详见第六章,响应式表单和组件交互。
最后,识别封装了您希望为应用程序创建的独特行为的自包含用户控件。用户控件可能会被创建为具有数据绑定属性和紧密耦合的控制器逻辑和模板的指令或组件。另一方面,组件将利用路由器生命周期事件来解析参数并对数据执行 CRUD 操作。在早期识别这些组件重用将导致创建更灵活的组件,可以在路由器协调下在多个上下文中重用,最大程度地实现代码重用。
创建 LemonMart
LemonMart 将是一个中型的业务应用程序,拥有超过 90 个代码文件。我们将从创建一个新的 Angular 应用程序开始,其中包括路由和 Angular Material 的配置。
创建一个以路由为先的应用程序
采用以路由为先的方法,我们将希望在应用程序早期启用路由:
- 您可以通过执行以下命令创建已经配置了路由的新应用程序:
确保未全局安装@angular/cli,否则可能会遇到错误:
$ npx @angular/cli new lemon-mart --routing
- 一个新的
AppRoutingModule文件已经为我们创建了:
src/app/app-routing.modules.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
我们将在 routes 数组中定义路由。请注意,routes 数组被传入以配置为应用程序的根路由,默认的根路由为/。
在配置您的RouterModule时,您可以传入其他选项来自定义路由器的默认行为,例如当您尝试加载已经显示的路由时,而不是不采取任何操作,您可以强制重新加载组件。要启用此行为,请创建您的路由器如下:RouterModule.forRoot(routes, { onSameUrlNavigation: 'reload' })。
- 最后,
AppRoutingModule被注册到AppModule中,如下所示:
src/app/app.module.ts ...
import { AppRoutingModule } from './app-routing.module';
@NgModule({
...
imports: [
AppRoutingModule
...
],
...
配置 Angular.json 和 Package.json
以下是第 2-6 章中涵盖的配置步骤的快速摘要。如果您对某个步骤不熟悉,请参考之前的章节。在继续之前,您应该完成这些步骤:
-
修改
angular.json和tslint.json以强制执行您的设置和编码标准。 -
安装
npm i -D prettier -
将
prettier设置添加到package.json -
将开发服务器端口配置为除
4200之外的其他端口,例如5000 -
添加
standardize脚本并更新start和build脚本 -
为 Docker 添加 npm 脚本到
package.json -
建立开发规范并在项目中记录,
npm i -D dev-norms然后npx dev-norms create -
如果您使用 VS Code,请设置
extensions.json和settings.json文件
您可以配置 TypeScript Hero 扩展以自动组织和修剪导入语句,只需将"typescriptHero.imports.organizeOnSave": true添加到settings.json中。如果与设置"files.autoSave": "onFocusChange"结合使用,您可能会发现该工具在您尝试输入时会积极清除未使用的导入。确保此设置适用于您,并且不会与任何其他工具或 VS Code 自己的导入组织功能发生冲突。
- 执行
npm run standardize
参考第三章,为生产发布准备 Angular 应用,以获取更多配置细节。
您可以在bit.ly/npmScriptsF…获取 Docker 的 npm 脚本,以及在bit.ly/npmScriptsF…获取 AWS 的 npm 脚本。
配置 Material 和样式
我们还需要设置 Angular Material 并配置要使用的主题,如第五章中所述,使用 Angular Material 增强 Angular 应用:
- 安装 Angular Material:
$ npx ng add @angular/material
$ npm i @angular/flex-layout hammerjs
$ npx ng g m material --flat -m app
-
导入和导出
MatButtonModule,MatToolbarModule和MatIconModule -
配置默认主题并注册其他 Angular 依赖项
-
将通用 css 添加到
styles.css中,如下所示,
src/styles.css
body {
margin: 0;
}
.margin-top {
margin-top: 16px;
}
.horizontal-padding {
margin-left: 16px;
margin-right: 16px;
}
.flex-spacer {
flex: 1 1 auto;
}
有关更多配置详细信息,请参阅第五章,使用 Angular Material 增强 Angular 应用。
设计 LemonMart
在构建从数据库到前端的基本路线图的同时,避免过度工程化非常重要。这个初始设计阶段对项目的长期健康和成功至关重要,团队之间任何现有的隔离必须被打破,并且整体技术愿景必须被团队的所有成员充分理解。这并不是说起来容易做起来难,关于这个话题已经有大量的书籍写成。
在工程领域,没有一个问题有唯一正确的答案,因此重要的是要记住没有一个人可以拥有所有答案,也没有一个人可以有清晰的愿景。技术和非技术领导者之间创造一个安全的空间,提供开放讨论和实验的机会是文化的一部分,这一点非常重要。能够在团队中面对这种不确定性所带来的谦卑和同理心与任何单个团队成员的技术能力一样重要。每个团队成员都必须习惯于把自己的自我放在一边,因为我们的集体目标将是在开发周期内发展和演变应用程序以适应不断变化的需求。如果你能够知道你已经成功了,那么你所创建的软件的各个部分都可以很容易地被任何人替换。
确定用户角色
我们设计的第一步是考虑您使用应用程序的原因。
我们为 LemonMart 设想了四种用户状态或角色:
-
认证用户,任何经过认证的用户都可以访问他们的个人资料
-
收银员,其唯一角色是为客户结账。
-
店员,其唯一角色是执行与库存相关的功能
-
经理,可以执行收银员和店员可以执行的所有操作,但也可以访问管理功能
有了这个想法,我们可以开始设计我们应用程序的高级设计。
使用站点地图确定高级模块
制作应用程序的高级站点地图,如下所示:
用户的登陆页面我使用了 MockFlow.com 的 SiteMap 工具来创建站点地图
在首次检查时,三个高级模块出现为延迟加载的候选项:
-
销售点(POS)
-
库存
-
经理
收银员只能访问 POS 模块和组件。店员只能访问库存模块,其中包括库存录入、产品和类别管理组件的额外屏幕。
库存页面
最后,管理者将能够通过管理模块访问所有三个模块,包括用户管理和收据查找组件。
管理页面
启用所有三个模块的延迟加载有很大好处,因为收银员和店员永远不会使用属于其他用户角色的组件,所以没有理由将这些字节发送到他们的设备上。这意味着当管理模块获得更多高级报告功能或新角色添加到应用程序时,POS 模块不会受到应用程序增长的带宽和内存影响。这意味着更少的支持电话,并且在同一硬件上保持一致的性能更长的时间。
生成启用路由的模块
现在我们已经定义了高级组件作为管理者、库存和 POS,我们可以将它们定义为模块。这些模块将与您迄今为止创建的模块不同,用于路由和 Angular Material。我们可以将用户配置文件创建为应用程序模块上的一个组件;但是,请注意,用户配置文件只会用于已经经过身份验证的用户,因此定义一个专门用于一般经过身份验证用户的第四个模块是有意义的。这样,您将确保您的应用程序的第一个有效载荷保持尽可能小。此外,我们将创建一个主页组件,用于包含我们应用程序的着陆体验,以便我们可以将实现细节从app.component中排除出去:
- 生成
manager,inventory,pos和user模块,指定它们的目标模块和路由功能:
$ npx ng g m manager -m app --routing
$ npx ng g m inventory -m app --routing
$ npx ng g m pos -m app --routing
$ npx ng g m user -m app --routing
如第一章中所讨论的设置您的开发环境,如果您已经配置npx自动识别ng作为命令,您可以节省更多按键,这样您就不必每次都添加npx到您的命令中。不要全局安装@angular/cli。请注意缩写命令结构,其中ng generate module manager变成ng g m manager,同样,--module变成了-m。
- 验证您是否没有 CLI 错误。
请注意,在 Windows 上使用npx可能会遇到错误,例如路径必须是字符串。收到未定义。这个错误似乎对命令的成功操作没有任何影响,这就是为什么始终要检查 CLI 工具生成的内容是至关重要的。
- 验证文件夹和文件是否已创建:
/src/app
│ app-routing.module.ts
│ app.component.css
│ app.component.html
│ app.component.spec.ts
│ app.component.ts
│ app.module.ts
│ material.module.ts
├───inventory
│ inventory-routing.module.ts
│ inventory.module.ts
├───manager
│ manager-routing.module.ts
│ manager.module.ts
├───pos
│ pos-routing.module.ts
│ pos.module.ts
└───user
user-routing.module.ts
user.module.ts
- 检查
ManagerModule的连接方式。
子模块实现了类似于app.module的@NgModule。最大的区别是子模块不实现bootstrap属性,这是你的根模块所需的,用于初始化你的 Angular 应用程序:
src/app/manager/manager.module.ts
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ManagerRoutingModule } from './manager-routing.module'
@NgModule({
imports: [CommonModule, ManagerRoutingModule],
declarations: [],
})
export class ManagerModule {}
由于我们指定了-m选项,该模块已被导入到app.module中:
src/app/app.module.ts
...
import { ManagerModule } from './manager/manager.module'
...
@NgModule({
...
imports: [
...
ManagerModule
],
...
此外,因为我们还指定了--routing选项,一个路由模块已经被创建并导入到ManagerModule中:
src/app/manager/manager-routing.module.ts
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = []
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ManagerRoutingModule {}
请注意,RouterModule正在使用forChild进行配置,而不是forRoot,这是AppRouting模块的情况。这样,路由器就能理解在不同模块上下文中定义的路由之间的正确关系,并且可以在这个例子中正确地在所有子路由前面添加/manager。
CLI 不尊重你的tslint.json设置。如果你已经正确配置了 VS Code 环境并使用 prettier,你的代码样式偏好将在你每个文件上工作时应用,或者在全局运行 prettier 命令时应用。
设计 home 路由
考虑以下模拟作为 LemonMart 的登陆体验:
LemonMart 登陆体验
与LocalCastWeather应用程序不同,我们不希望所有这些标记都在App组件中。App组件是整个应用程序的根元素;因此,它应该只包含将在整个应用程序中持续出现的元素。在下面的注释模拟中,标记为 1 的工具栏将在整个应用程序中持续存在。
标记为 2 的区域将容纳 home 组件,它本身将包含一个登录用户控件,标记为 3:
LemonMart 布局结构
在 Angular 中,将默认或登陆组件创建为单独的元素是最佳实践。这有助于减少必须加载的代码量和在每个页面上执行的逻辑,但在利用路由器时也会导致更灵活的架构:
使用内联模板和样式生成home组件:
$ npx ng g c home -m app --inline-template --inline-style
现在,你已经准备好配置路由器了。
设置默认路由
让我们开始为 LemonMart 设置一个简单的路由:
- 配置你的
home路由:
src/app/app-routing.module.ts
...
const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', component: HomeComponent },
]
...
我们首先为'home'定义一个路径,并通过设置组件属性来告知路由渲染HomeComponent。然后,我们将应用的默认路径''重定向到'/home'。通过设置pathMatch属性,我们始终确保主页路由的这个非常特定的实例将作为着陆体验呈现。
-
创建一个带有内联模板的
pageNotFound组件 -
为
PageNotFoundComponent配置通配符路由:
src/app/app-routing.module.ts
...
const routes: Routes = [
...
{ path: '**', component: PageNotFoundComponent }
]
...
这样,任何未匹配的路由都将被重定向到PageNotFoundComponent。
RouterLink
当用户登陆到PageNotFoundComponent时,我们希望他们通过RouterLink重定向到HomeComponent:
- 实现一个内联模板,使用
routerLink链接回主页:
src/app/page-not-found/page-not-found.component.ts
...
template: `
<p>
This page doesn't exist. Go back to <a routerLink="/home">home</a>.
</p>
`,
...
这种导航也可以通过<a href>标签实现;然而,在更动态和复杂的导航场景中,您将失去诸如自动活动链接跟踪或动态链接生成等功能。
Angular 的引导过程将确保AppComponent在您的index.html中的<app-root>元素内。然而,我们必须手动定义我们希望HomeComponent呈现的位置,以完成路由器配置。
路由出口
AppComponent被视为在app-routing.module中定义的根路由的根元素,这使我们能够在此根元素内定义 outlets,以使用<router-outlet>元素动态加载任何我们希望的内容:
-
配置
AppComponent以使用内联模板和样式 -
为您的应用程序添加工具栏
-
将您的应用程序名称作为按钮链接添加,以便在点击时将用户带到主页
-
添加
<router-outlet>以渲染内容:
src/app/app.component.ts
...
template: `
<mat-toolbar color="primary">
<a mat-button routerLink="/home"><h1>LemonMart</h1></a>
</mat-toolbar>
<router-outlet></router-outlet>
`,
现在,主页的内容将在<router-outlet>内呈现。
品牌、自定义和 Material 图标
为了构建一个吸引人且直观的工具栏,我们必须向应用引入一些图标和品牌,以便用户可以通过熟悉的图标轻松浏览应用。
品牌
在品牌方面,您应该确保您的 Web 应用程序具有自定义色板,并与桌面和移动浏览器功能集成,以展示您应用的名称和图标。
色板
使用 Material Color 工具选择一个色板,如第五章中所讨论的,使用 Angular Material 增强 Angular 应用。这是我为 LemonMart 选择的色板:
https://material.io/color/#!/?view.left=0&view.right=0&primary.color=2E7D32&secondary.color=C6FF00
实现浏览器清单和图标
您需要确保浏览器在浏览器选项卡中显示正确的标题文本和图标。此外,应创建一个清单文件,为各种移动操作系统实现特定的图标,以便用户将您的网站固定在手机上时,会显示一个理想的图标,类似于手机上的其他应用图标。这将确保如果用户将您的 Web 应用添加到其移动设备的主屏幕上,他们将获得一个本地外观的应用图标:
-
从设计师或网站(如
www.flaticon.com)获取您网站标志的 SVG 版本 -
在这种情况下,我将使用一个特定的柠檬图片:
LemonMart 的标志性标志在使用互联网上找到的图像时,请注意适用的版权。在这种情况下,我已经购买了许可证以便发布这个柠檬标志,但是您可以在以下网址获取您自己的副本,前提是您提供图像作者所需的归属声明:
www.flaticon.com/free-icon/lemon_605070。
-
使用
realfavicongenerator.net等工具生成favicon.ico和清单文件 -
根据您的喜好调整 iOS、Android、Windows Phone、macOS 和 Safari 的设置
-
确保设置一个版本号,favicons 可能会因缓存而臭名昭著;一个随机的版本号将确保用户始终获得最新版本
-
下载并提取生成的
favicons.zip文件到您的src文件夹中。 -
编辑
angular.json文件以在您的应用程序中包含新的资产:
angular.json
"apps": [
{
...
"assets": [
"src/assets",
"src/favicon.ico",
"src/android-chrome-192x192.png",
"src/favicon-16x16.png",
"src/mstile-310x150.png",
"src/android-chrome-512x512.png",
"src/favicon-32x32.png",
"src/mstile-310x310.png",
"src/apple-touch-icon.png",
"src/manifest.json",
"src/mstile-70x70.png",
"src/browserconfig.xml",
"src/mstile-144x144.png",
"src/safari-pinned-tab.svg",
"src/mstile-150x150.png"
]
- 将生成的代码插入到
index.html的<head>部分中:
src/index.html
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=rMlKOnvxlK">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=rMlKOnvxlK">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=rMlKOnvxlK">
<link rel="manifest" href="/manifest.json?v=rMlKOnvxlK">
<link rel="mask-icon" href="/safari-pinned-tab.svg?v=rMlKOnvxlK" color="#b3ad2d">
<link rel="shortcut icon" href="/favicon.ico?v=rMlKOnvxlK">
<meta name="theme-color" content="#ffffff">
- 确保您的新 favicon 显示正确
为了进一步推广您的品牌,请考虑配置自定义的 Material 主题并利用material.io/color,如第五章,使用 Angular Material 增强 Angular 应用中所讨论的那样。
自定义图标
现在,让我们在您的 Angular 应用程序中添加您的自定义品牌。您将需要用于创建 favicon 的 svg 图标:
-
将图像放在
src/app/assets/img/icons下,命名为lemon.svg -
将
HttpClientModule导入AppComponent,以便可以通过 HTTP 请求.svg文件 -
更新
AppComponent以注册新的 svg 文件作为图标:
src/app/app.component.ts import { DomSanitizer } from '@angular/platform-browser'
...
export class AppComponent {
constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer) {
iconRegistry.addSvgIcon(
'lemon',
sanitizer.bypassSecurityTrustResourceUrl('assets/img/icons/lemon.svg')
)
}
}
- 将图标添加到工具栏:
src/app/app.component.ts
template: `
<mat-toolbar color="primary">
<mat-icon svgIcon="lemon"></mat-icon>
<a mat-button routerLink="/home"><h1>LemonMart</h1></a>
</mat-toolbar>
<router-outlet></router-outlet>
`,
现在让我们为菜单、用户资料和注销添加剩余的图标。
Material 图标
Angular Material 可以与 Material Design 图标直接配合使用,可以在index.html中将其作为 Web 字体导入到您的应用程序中。也可以自行托管字体;但是,如果您选择这条路,您也无法获得用户的浏览器在访问其他网站时已经缓存了字体的好处,从而节省了下载 42-56 KB 文件的速度和延迟。完整的图标列表可以在material.io/icons/找到。
现在让我们使用一些图标更新工具栏,并为主页设置一个最小的模板,用于模拟登录按钮:
- 确保 Material 图标
<link>标签已添加到index.html:
src/index.html
<head>
...
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
有关如何自行托管的说明可以在google.github.io/material-design-icons/#getting-icons的自行托管部分找到。
配置完成后,使用 Material 图标非常容易。
-
更新工具栏,将菜单按钮放置在标题左侧。
-
添加一个
fxFlex,以便将剩余的图标右对齐。 -
添加用户个人资料和注销图标:
src/app/app.component.ts
template: `
<mat-toolbar color="primary">
<button mat-icon-button><mat-icon>menu</mat-icon></button>
<mat-icon svgIcon="lemon"></mat-icon>
<a mat-button routerLink="/home"><h1>LemonMart</h1></a>
<span class="flex-spacer"></span>
<button mat-icon-button><mat-icon>account_circle</mat-icon></button>
<button mat-icon-button><mat-icon>lock_open</mat-icon></button>
</mat-toolbar>
<router-outlet></router-outlet>
`,
- 添加一个最小的登录模板:
src/app/home/home.component.ts
styles: [`
div[fxLayout] {margin-top: 32px;}
`],
template: `
<div fxLayout="column" fxLayoutAlign="center center">
<span class="mat-display-2">Hello, Lemonite!</span>
<button mat-raised-button color="primary">Login</button>
</div>
`
您的应用程序应该类似于这个屏幕截图:
LemonMart with minimal login
在实现和显示/隐藏菜单、个人资料和注销图标方面还有一些工作要做,考虑到用户的身份验证状态。我们将在第九章中涵盖这些功能,设计身份验证和授权。现在您已经为应用程序设置了基本路由,需要学习如何在移动到设置带有子组件的延迟加载模块之前调试您的 Angular 应用程序。
Angular Augury
Augury 是用于调试和分析 Angular 应用程序的 Chrome Dev Tools 扩展。这是一个专门为帮助开发人员直观地浏览组件树、检查路由状态并通过源映射在生成的 JavaScript 代码和开发人员编写的 TypeScript 代码之间启用断点调试的工具。您可以从augury.angular.io下载 Augury。安装后,当您为 Angular 应用程序打开 Chrome Dev Tools 时,您会注意到一个新的 Augury 标签,如下所示:
Chrome Dev Tools Augury
Augury 在理解您的 Angular 应用程序在运行时的行为方面提供了有用和关键的信息:
-
当前的 Angular 版本列出为版本 5.1.2
-
组件树
-
路由器树显示了应用程序中配置的所有路由
-
NgModules 显示了
AppModule和应用程序的子模块
组件树
组件树选项卡显示了所有应用程序组件之间的关系以及它们如何相互作用:
- 选择特定组件,如
HomeComponent,如下所示:
Augury 组件树
右侧的属性选项卡将显示一个名为“查看源代码”的链接,您可以使用它来调试您的组件。在下面更深的地方,您将能够观察组件属性的状态,例如 displayLogin 布尔值,包括您注入到组件中的服务及其状态。
您可以通过双击值来更改任何属性的值。例如,如果您想将 displayLogin 的值更改为false,只需双击包含 true 值的蓝色框并输入 false。您将能够观察到您的更改在您的 Angular 应用程序中的影响。
为了观察HomeComponent的运行时组件层次结构,您可以观察注射器图。
- 单击注射器图选项卡,如下所示:
Augury 注射器图
该视图显示了您选择的组件是如何被渲染的。在这种情况下,我们可以观察到HomeComponent在AppComponent内部被渲染。这种可视化在追踪陌生代码库中特定组件的实现或存在深层组件树的情况下非常有帮助。
断点调试
让我再次重申,console.log语句绝对不应该提交到您的代码库中。一般来说,它们是浪费您的时间,因为它需要编辑代码,然后清理您的代码。此外,Augury 已经提供了您组件的状态,因此在简单的情况下,您应该能够利用它来观察或强制状态。
有一些特定用例,其中console.log语句可能会有用。这些大多是并行操作的异步工作流,并且依赖于及时的用户交互。在这些情况下,控制台日志可以帮助您更好地理解事件流和各个组件之间的交互。
Augury 目前还不够复杂,无法解决异步数据或通过函数返回的数据。还有其他常见情况,你可能希望观察属性的状态在设置时,甚至能够实时更改它们的值,以强制代码执行if-else或switch语句中的分支逻辑。对于这些情况,你应该使用断点调试。
假设HomeComponent上存在一些基本逻辑,它根据从AuthService获取的isAuthenticated值设置了一个displayLogin布尔值,如下所示:
src/app/home/home.component.ts
...
import { AuthService } from '../auth.service'
...
export class HomeComponent implements OnInit {
displayLogin = true
constructor(private authService: AuthService) {}
ngOnInit() {
this.displayLogin = !this.authService.isAuthenticated()
}
}
现在观察displayLogin的值和isAuthenticated函数在设置时的状态,然后观察displayLogin值的变化:
-
点击
HomeComponent上的查看源链接 -
在
ngOnInit函数内的第一行上设置一个断点 -
刷新页面
-
Chrome Dev Tools 将切换到源标签页,你会看到断点被触发,如蓝色所示:
Chrome Dev Tools 断点调试
-
悬停在
this.displayLogin上并观察其值设置为true -
如果悬停在
this.authService.isAuthenticated()上,你将无法观察到其值
当你的断点被触发时,你可以在控制台中访问当前状态的作用域,这意味着你可以执行函数并观察其值。
- 在控制台中执行
isAuthenticated():
> !this.authService.isAuthenticated()
true
你会注意到它返回了true,这就是this.displayLogin的设置值。你仍然可以在控制台中强制转换displayLogin的值。
- 将
displayLogin设置为false:
> this.displayLogin = false
false
如果你观察displayLogin的值,无论是悬停在上面还是从控制台中检索,你会发现值被设置为false。
利用断点调试基础知识,你可以在不改变源代码的情况下调试复杂的场景。
路由树
路由树标签将显示路由的当前状态。这可以是一个非常有用的工具,可以帮助你可视化路由和组件之间的关系,如下所示:
Augury 路由树
前面的路由树展示了一个深度嵌套的路由结构,其中包含主细节视图。你可以通过点击圆形节点来查看渲染给定组件所需的绝对路径和参数。
如您所见,对于PersonDetailsComponent来说,确定需要渲染主细节视图中的详细部分所需的参数集可能会变得复杂。
NgModules
NgModules 选项卡显示了当前加载到内存中的AppModule和任何其他子模块:
-
启动应用程序的
/home路由 -
观察 NgModules 选项卡,如下所示:
Augury NgModules
您会注意到只有AppModule被加载。但是,由于我们的应用程序采用了延迟加载的架构,我们的其他模块尚未被加载。
-
导航到
ManagerModule中的一个页面 -
然后,导航到
UserModule中的一个页面 -
最后,导航回到
/home路由 -
观察 NgModules 选项卡,如下所示:
Augury NgModules with Three Modules
- 现在,您会注意到已经加载了三个模块到内存中。
NgModules 是一个重要的工具,可以可视化设计和架构的影响。
具有延迟加载的子模块
延迟加载允许由 webpack 驱动的 Angular 构建过程将我们的 Web 应用程序分隔成不同的 JavaScript 文件,称为块。通过将应用程序的部分分离成单独的子模块,我们允许这些模块及其依赖项被捆绑到单独的块中,从而将初始 JavaScript 捆绑包大小保持在最小限度。随着应用程序的增长,首次有意义的绘制时间保持恒定,而不是随着时间的推移不断增加。延迟加载对于实现可扩展的应用程序架构至关重要。
现在我们将介绍如何设置具有组件和路由的子模块。我们还将使用 Augury 来观察我们各种路由配置的效果。
配置具有组件和路由的子模块
管理模块需要一个着陆页,如此模拟所示:
Manager's Dashboard 让我们从为
ManagerModule创建主屏幕开始:
- 创建
ManagerHome组件:
$ npx ng g c manager/managerHome -m manager -s -t
为了在manager文件夹下创建新组件,我们必须在组件名称前面加上manager/前缀。此外,我们指定该组件应该被导入并在ManagerModule中声明。由于这是另一个着陆页,它不太可能复杂到需要单独的 HTML 和 CSS 文件。您可以使用--inline-style(别名-s)和/或--inline-template(别名-t)来避免创建额外的文件。
- 验证您的文件夹结构如下:
/src
├───app
│ │
│ ├───manager
│ │ │ manager-routing.module.ts
│ │ │ manager.module.ts
│ │ │
│ │ └───manager-home
│ │ manager-home.component.spec.ts
│ │ manager-home.component.ts
- 使用
manager-routing.module配置ManagerHome组件的路由,类似于我们如何使用app-route.module配置Home组件:
src/app/manager/manager-routing.module.ts
import { ManagerHomeComponent } from './manager-home/manager-home.component'
import { ManagerComponent } from './manager.component'
const routes: Routes = [
{
path: '',
component: ManagerComponent,
children: [
{ path: '', redirectTo: '/manager/home', pathMatch: 'full' },
{ path: 'home', component: ManagerHomeComponent },
],
},
]
您会注意到http://localhost:5000/manager实际上还没有解析到一个组件,因为我们的 Angular 应用程序不知道ManagerModule的存在。让我们首先尝试强制急加载的方法,导入manager.module并注册 manager 路由到我们的应用程序。
急加载
这一部分纯粹是为了演示我们迄今为止学到的导入和注册路由的概念,并不会产生可扩展的解决方案,无论是急加载还是懒加载组件:
- 将
manager.module导入到app.module中:
src/app/app.module.ts
import { ManagerModule } from './manager/manager.module'
...
imports: [
...
ManagerModule,
]
您会注意到http://localhost:5000/manager仍然没有渲染其主组件。
- 使用 Augury 调试路由状态,如下所示:
带有急加载的路由树
-
似乎
/manager路径已经正确注册并指向正确的组件ManagerHomeComponent。问题在于app-routing.module中配置的rootRouter并不知道/manager路径,因此**路径优先,并渲染PageNotFoundComponent。 -
作为最后的练习,在
app-routing.module中实现'manager'路径,并像平常一样将ManagerHomeComponent分配给它:
src/app/app-routing.module.ts
import { ManagerHomeComponent } from './manager/manager-home/manager-home.component'
...
const routes: Routes = [
...
{ path: 'manager', component: ManagerHomeComponent },
{ path: '**', component: PageNotFoundComponent },
]
现在您会注意到http://localhost:5000/manager正确显示manager-home works!;然而,如果您通过 Augury 调试路由状态,您会注意到/manager注册了两次。
这个解决方案不太可扩展,因为它强制所有开发人员维护一个单一的主文件来导入和配置每个模块。它容易产生合并冲突和沮丧,希望团队成员不会多次注册相同的路由。
可以设计一个解决方案将模块分成多个文件。您可以在manager.module中实现 Route 数组并导出它,而不是标准的*-routing.module。考虑以下示例:
example/manager/manager.module
export const managerModuleRoutes: Routes = [
{ path: '', component: ManagerHomeComponent }
]
然后需要将这些文件单独导入到app-routing.module中,并使用children属性进行配置:
example/app-routing.module
import { managerModuleRoutes } from './manager/manager.module'
...
{ path: 'manager', children: managerModuleRoutes },
这个解决方案将起作用,这是一个正确的解决方案,正如 Augury 路由树所示:
带有子路由的路由树
没有重复的注册,因为我们删除了manager-routing.module。此外,我们不必在manager.module之外导入ManagerHomeComponent,从而得到一个更好的可扩展解决方案。然而,随着应用程序的增长,我们仍然必须在app.module中注册模块,并且子模块仍然以潜在不可预测的方式耦合到父app.module中。此外,这段代码无法被分块,因为使用import导入的任何代码都被视为硬依赖。
懒加载
现在您了解了模块的急加载如何工作,您将能够更好地理解我们即将编写的代码,否则这些代码可能看起来像黑魔法,而神奇(也就是被误解的)代码总是导致意大利面式架构。
我们现在将急加载解决方案演变为懒加载解决方案。为了从不同模块加载路由,我们知道不能简单地导入它们,否则它们将被急加载。答案在于在app-routing.module.ts中使用loadChildren属性配置路由,该属性使用字符串通知路由器如何加载子模块:
-
确保您打算懒加载的任何模块都不被导入到
app.module中 -
删除添加到
ManagerModule的任何路由 -
确保
ManagerRoutingModule被导入到ManagerModule中。 -
使用
loadChildren属性实现或更新管理器路径:
src/app/app-routing.module.ts
import {
...
const routes: Routes = [
...
{ path: 'manager', loadChildren: './manager/manager.module#ManagerModule' },
{ path: '**', component: PageNotFoundComponent },
]
...
懒加载是通过一个巧妙的技巧实现的,避免使用import语句。定义一个具有两部分的字符串文字,其中第一部分定义了模块文件的位置,例如app/manager/manager.module,第二部分定义了模块的类名。在构建过程和运行时可以解释字符串,以动态创建块,加载正确的模块并实例化正确的类。ManagerModule然后就像它自己的 Angular 应用程序一样,管理着所有子依赖项和路由。
- 更新
manager-routing.module路由,考虑到 manager 现在是它们的根路由:
src/app/manager/manager-routing.module.ts
const routes: Routes = [
{ path: '', redirectTo: '/manager/home', pathMatch: 'full' },
{ path: 'home', component: ManagerHomeComponent },
]
我们现在可以将ManagerHomeComponent的路由更新为更有意义的'home'路径。这个路径不会与app-routing.module中找到的路径冲突,因为在这个上下文中,'home'解析为'manager/home',同样,当路径为空时,URL 看起来像http://localhost:5000/manager。
- 通过查看 Augury 来确认懒加载是否起作用,如下所示:
带有延迟加载的路由树
ManagerHomeComponent的根节点现在命名为manager [Lazy]。
完成骨架走向
使用我们在本章前面创建的 LemonMart 站点地图,我们需要完成应用程序的骨架导航体验。为了创建这种体验,我们需要创建一些按钮来链接所有模块和组件。我们将逐个模块进行:
- 在开始之前,更新
home.component上的登录按钮,链接到Manager模块:
src/app/home/home.component.ts
...
<button mat-raised-button color="primary" routerLink="/manager">Login as Manager</button>
...
管理模块
由于我们已经为ManagerModule启用了延迟加载,让我们继续完成它的其他导航元素。
在当前设置中,ManagerHomeComponent在app.component中定义的<router-outlet>中呈现,因此当用户从HomeComponent导航到ManagerHomeComponent时,app.component中实现的工具栏保持不变。如果我们在ManagerModule中实现类似的工具栏,我们可以为跨模块导航子页面创建一致的用户体验。
为了使这个工作,我们需要复制app.component和home/home.component之间的父子关系,其中父级实现工具栏和<router-outlet>,以便子元素可以在其中呈现:
- 首先创建基本的
manager组件:
$ npx ng g c manager/manager -m manager --flat -s -t
--flat选项跳过目录创建,直接将组件放在manager文件夹下,就像app.component直接放在app文件夹下一样。
- 使用
activeLink跟踪实现导航工具栏:
src/app/manager/manager.component.ts
styles: [`
div[fxLayout] {margin-top: 32px;}
`, `
.active-link {
font-weight: bold;
border-bottom: 2px solid #005005;
}`
],
template: `
<mat-toolbar color="accent">
<a mat-button routerLink="/manager/home" routerLinkActive="active-link">Manager's Dashboard</a>
<a mat-button routerLink="/manager/users" routerLinkActive="active-link">User Management</a>
<a mat-button routerLink="/manager/receipts" routerLinkActive="active-link">Receipt Lookup</a>
</mat-toolbar>
<router-outlet></router-outlet>
`
需要注意的是,子模块不会自动访问父模块中创建的服务或组件。这是为了保持解耦架构的重要默认行为。然而,在某些情况下,有必要共享一些代码。在这种情况下,需要重新导入mat-toolbar。由于MatToolbarModule已经在src/app/material.module.ts中加载,我们可以将这个模块导入到manager.module.ts中,这样做不会产生性能或内存开销。
ManagerComponent应该被导入到ManagerModule中:
src/app/manager/manager.module.ts
import { MaterialModule } from '../material.module'
import { ManagerComponent } from './manager.component'
...
imports: [... MaterialModule, ManagerComponent],
- 为子页面创建组件:
$ npx ng g c manager/userManagement -m manager
$ npx ng g c manager/receiptLookup -m manager
- 创建父/子路由。我们知道我们需要以下路由才能导航到我们的子页面,如下所示:
example
{ path: '', redirectTo: '/manager/home', pathMatch: 'full' },
{ path: 'home', component: ManagerHomeComponent },
{ path: 'users', component: UserManagementComponent },
{ path: 'receipts', component: ReceiptLookupComponent },
为了定位在manager.component中定义的<router-outlet>,我们需要首先创建一个父路由,然后为子页面指定路由:
src/app/manager/manager-routing.module.ts
...
const routes: Routes = [
{
path: '', component: ManagerComponent, children: [
{ path: '', redirectTo: '/manager/home', pathMatch: 'full' },
{ path: 'home', component: ManagerHomeComponent },
{ path: 'users', component: UserManagementComponent },
{ path: 'receipts', component: ReceiptLookupComponent },
]
},
]
现在您应该能够浏览应用程序。当您单击“登录为经理”按钮时,您将被带到此处显示的页面。可单击的目标已突出显示,如下所示:
如果您单击 LemonMart,您将被带到主页。如果您单击“经理仪表板”,“用户管理”或“收据查找”,您将被导航到相应的子页面,而工具栏上的活动链接将以粗体和下划线显示。
用户模块
登录后,用户将能够通过侧边导航菜单访问其个人资料,并查看他们可以在 LemonMart 应用程序中访问的操作列表。在第九章中,设计身份验证和授权,当我们实现身份验证和授权时,我们将从服务器接收用户的角色。根据用户的角色,我们将能够自动导航或限制用户可以看到的选项。我们将在此模块中实现这些组件,以便它们只在用户登录后加载一次。为了完成骨架的搭建,我们将忽略与身份验证相关的问题:
- 创建必要的组件:
$ npx ng g c user/profile -m user
$ npx ng g c user/logout -m user -t -s
$ npx ng g c user/navigationMenu -m user -t -s
- 实现路由:
从在app-routing中实现懒加载开始:
src/app/app-routing.module.ts
...
{ path: 'user', loadChildren: 'app/user/user.module#UserModule' },
确保app-routing.module中的PageNotFoundComponent路由始终是最后一个路由。
现在在user-routing中实现子路由:
src/app/user/user-routing.module.ts
...
const routes: Routes = [
{ path: 'profile', component: ProfileComponent },
{ path: 'logout', component: LogoutComponent },
]
我们正在为NavigationMenuComponent实现路由,因为它将直接用作 HTML 元素。此外,由于userModule没有着陆页面,因此没有定义默认路径。
- 连接用户和注销图标:
src/app/app.component.ts ...
<mat-toolbar>
...
<button mat-mini-fab routerLink="/user/profile" matTooltip="Profile" aria-label="User Profile"><mat-icon>account_circle</mat-icon></button>
<button mat-mini-fab routerLink="/user/logout" matTooltip="Logout" aria-label="Logout"><mat-icon>lock_open</mat-icon></button>
</mat-toolbar>
图标按钮可能会让人费解,因此最好为它们添加工具提示。为了使工具提示起作用,请从mat-icon-button指令切换到mat-mini-fab指令,并确保在material.module中导入MatTooltipModule。此外,确保为仅包含图标的按钮添加aria-label,以便依赖屏幕阅读器的残障用户仍然可以浏览您的 Web 应用程序。
- 确保应用程序正常运行。
请注意,两个按钮彼此之间距离太近,如下所示:
- 您可以通过在
<mat-toolbar>中添加fxLayoutGap="8px"来解决图标布局问题;然而,现在柠檬标志与应用程序名称相距太远,如图所示:
带有填充图标的工具栏
- 可以通过合并图标和按钮来解决标志布局问题:
src/app/app.component.ts ...<mat-toolbar> ...
<a mat-icon-button routerLink="/home"><mat-icon svgIcon="lemon"></mat-icon><span class="mat-h2">LemonMart</span></a>
...
</mat-toolbar>
如下截图所示,分组修复了布局问题:
带有分组和填充元素的工具栏
从用户体验的角度来看,这更加理想;现在用户也可以通过点击柠檬返回到主页。
POS 和库存模块
我们的基本框架假定经理的角色。为了能够访问我们即将创建的所有组件,我们需要使经理能够访问 pos 和 inventory 模块。
更新ManagerComponent,添加两个新按钮:
src/app/manager/manager.component.ts
<mat-toolbar color="accent" fxLayoutGap="8px">
...
<span class="flex-spacer"></span>
<button mat-mini-fab routerLink="/inventory" matTooltip="Inventory" aria-label="Inventory"><mat-icon>list</mat-icon></button>
<button mat-mini-fab routerLink="/pos" matTooltip="POS" aria-label="POS"><mat-icon>shopping_cart</mat-icon></button>
</mat-toolbar>
请注意,这些路由链接将会将我们从ManagerModule中导航出去,因此工具栏消失是正常的。
现在,你需要实现剩下的两个模块。
POS 模块
POS 模块与用户模块非常相似,只是PosComponent将成为默认路由。这将是一个复杂的组件,带有一些子组件,因此请确保它是在一个目录中创建的:
-
创建
PosComponent -
将
PosComponent注册为默认路由 -
为
PosModule配置延迟加载 -
确保应用程序正常运行
库存模块
库存模块与ManagerModule非常相似,如图所示:
库存仪表盘模拟
-
创建基本的
Inventory组件 -
注册
MaterialModule -
创建库存仪表盘、库存录入、产品和类别组件
-
在
inventory-routing.module中配置父子路由 -
为
InventoryModule配置延迟加载 -
确保应用程序正常运行,如下所示:
LemonMart 库存仪表盘
现在应用程序的基本框架已经完成,重要的是检查路由树,以确保延迟加载已经正确配置,并且模块没有意外地急加载。
检查路由树
导航到应用程序的基本路由,并使用 Augury 检查路由树,如图所示:
急加载错误的路由树
除了最初需要的组件之外,其他所有内容都应该用[Lazy]属性标记。如果由于某种原因,路由没有用[Lazy]标记,那么它们很可能被错误地导入到app.module或其他组件中。
在上面的截图中,您可能会注意到ProfileComponent和LogoutComponent是急加载的,而user模块被正确标记为[Lazy]。即使通过工具和代码库进行多次视觉检查,也可能让您寻找罪魁祸首。但是,如果您全局搜索UserModule,您很快就会发现它被导入到app.module中。
为了安全起见,请确保删除app.module中的模块导入语句,您的文件应该像下面这样:
src/app/app.module.ts
import { FlexLayoutModule } from '@angular/flex-layout'
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { MaterialModule } from './material.module'
import { HomeComponent } from './home/home.component'
import { PageNotFoundComponent } from './page-not-found/page-not-found.component'
import { HttpClientModule } from '@angular/common/http'
@NgModule({
declarations: [AppComponent, HomeComponent, PageNotFoundComponent],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
MaterialModule,
HttpClientModule,
FlexLayoutModule,
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
下一张截图显示了修正后的路由器树:
带有延迟加载的路由器树确保在继续之前执行
npm test和npm run e2e时没有错误。
通用测试模块
现在我们有很多模块要处理,配置每个规范文件的导入和提供者变得很繁琐。为此,我建议创建一个通用测试模块,其中包含您可以在各个领域重复使用的通用配置。
首先创建一个新的.ts文件。
-
创建
common/common.testing.ts -
用通用测试提供者、虚拟和模块填充它,如下所示:
我已经提供了ObservableMedia、MatIconRegistry、DomSanitizer的虚拟实现,以及commonTestingProviders和commonTestingModules的数组。
src/app/common/common.testing.ts
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { MediaChange } from '@angular/flex-layout'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { SafeResourceUrl, SafeValue } from '@angular/platform-browser'
import { NoopAnimationsModule } from '@angular/platform-browser/animations'
// tslint:disable-next-line:max-line-length
import { SecurityContext } from '@angular/platform-browser/src/security/dom_sanitization_service'
import { RouterTestingModule } from '@angular/router/testing'
import { Observable, Subscription, of } from 'rxjs'
import { MaterialModule } from '../material.module'
const FAKE_SVGS = {
lemon: '<svg><path id="lemon" name="lemon"></path></svg>',
}
export class ObservableMediaFake {
isActive(query: string): boolean {
return false
}
asObservable(): Observable<MediaChange> {
return of({} as MediaChange)
}
subscribe(
next?: (value: MediaChange) => void,
error?: (error: any) => void,
complete?: () => void
): Subscription {
return new Subscription()
}
}
export class MatIconRegistryFake {
_document = document
addSvgIcon(iconName: string, url: SafeResourceUrl): this {
// this.addSvgIcon('lemon', 'lemon.svg')
return this
}
getNamedSvgIcon(name: string, namespace: string = ''): Observable<SVGElement> {
return of(this._svgElementFromString(FAKE_SVGS.lemon))
}
private _svgElementFromString(str: string): SVGElement {
if (this._document || typeof document !== 'undefined') {
const div = (this._document || document).createElement('DIV')
div.innerHTML = str
const svg = div.querySelector('svg') as SVGElement
if (!svg) {
throw Error('<svg> tag not found')
}
return svg
}
}
}
export class DomSanitizerFake {
bypassSecurityTrustResourceUrl(url: string): SafeResourceUrl {
return {} as SafeResourceUrl
}
sanitize(context: SecurityContext, value: SafeValue | string | null): string | null {
return value ? value.toString() : null
}
}
export const commonTestingProviders: any[] = [
// intentionally left blank
]
export const commonTestingModules: any[] = [
FormsModule,
ReactiveFormsModule,
MaterialModule,
NoopAnimationsModule,
HttpClientTestingModule,
RouterTestingModule,
]
现在让我们看一下这个共享配置文件的示例用法:
src/app/app.component.spec.ts import { commonTestingModules,
commonTestingProviders,
MatIconRegistryFake,
DomSanitizerFake,
ObservableMediaFake,
} from './common/common.testing'
import { ObservableMedia } from '@angular/flex-layout'
import { MatIconRegistry } from '@angular/material'
import { DomSanitizer } from '@angular/platform-browser'
...
TestBed.configureTestingModule({
imports: commonTestingModules,
providers: commonTestingProviders.concat([
{ provide: ObservableMedia, useClass: ObservableMediaFake },
{ provide: MatIconRegistry, useClass: MatIconRegistryFake },
{ provide: DomSanitizer, useClass: DomSanitizerFake },
]),
declarations: [AppComponent],
...
大多数其他模块只需要导入commonTestingModules。
在所有测试通过之前不要继续前进!
总结
在本章中,您学会了如何有效地使用 Angular CLI 来创建主要的 Angular 组件和脚手架。您创建了您的应用的品牌,利用了自定义和内置的 Material 图标。您学会了如何使用 Augury 调试复杂的 Angular 应用。最后,您开始构建基于路由器的应用程序,尽早定义用户角色,考虑懒加载的设计,并尽早确定行走骨架导航体验。
总结一下,为了实现基于路由器的实现,您需要这样做:
-
尽早定义用户角色
-
考虑懒加载的设计
-
实现一个行走骨架导航体验
-
围绕主要数据组件进行设计
-
强制执行解耦的组件架构
-
区分用户控件和组件
-
最大程度地重用代码
在这一章中,您执行了 1-3 步;在接下来的三章中,您将执行 4-7 步。在第八章中,《持续集成和 API 设计》,我们将讨论围绕主要数据组件进行设计,并启用持续集成以确保高质量的可交付成果。在第九章中,《设计身份验证和授权》,我们将深入探讨安全考虑,并设计有条件的导航体验。在第十章中,《Angular 应用设计和配方》,我们将通过坚持解耦的组件架构,巧妙选择创建用户控件与组件,并利用各种 TypeScript、RxJS 和 Angular 编码技术来最大程度地重用代码。
第八章:持续集成和 API 设计
在我们开始为我们的 LOB 应用 LemonMart 构建更复杂的功能之前,我们需要确保我们创建的每个代码推送都通过了测试,符合编码标准,并且是团队成员可以运行测试的可执行构件,因为我们继续进一步开发我们的应用。同时,我们需要开始考虑我们的应用将如何与后端服务器进行通信。无论是您、您的团队还是其他团队将创建新的 API,都很重要的是 API 设计能够满足前端和后端架构的需求。为了确保开发过程顺利进行,需要一个强大的机制来为 API 创建一个可访问的、实时的文档。持续集成(CI)可以解决第一个问题,而 Swagger 非常适合解决 API 设计、文档和测试需求。
持续集成对于确保质量可交付成果至关重要,它会在每次代码推送时构建和执行测试。建立 CI 环境可能会耗费时间,并需要对所使用的工具有专门的知识。CircleCI 是一个成熟的基于云的 CI 服务,拥有免费的套餐和有用的文章,可以让您尽可能少地进行配置就能开始使用。我们将介绍一种基于 Docker 的方法,可以在大多数 CI 服务上运行,使您的特定配置知识保持相关,并将 CI 服务知识降至最低。
全栈开发的另一个方面是,您可能会同时开发应用程序的前端和后端。无论您是独自工作,还是与团队或多个团队合作,建立数据契约都是至关重要的,以确保您不会在最后关头遇到集成挑战。我们将使用 Swagger 为 REST API 定义数据契约,然后创建一个模拟服务器,您的 Angular 应用程序可以向其发出 HTTP 调用。对于后端开发,Swagger 可以作为生成样板代码的良好起点,并且可以作为 API 的实时文档和测试 UI。
在本章中,您将学习以下内容:
-
使用 CircleCI 的 CI
-
使用 Swagger 进行 API 设计
本章需要以下内容:
-
一个免费的 CircleCI 账户
-
Docker
持续集成
持续集成的目标是在每次代码推送时实现一致且可重复的环境,用于构建、测试和生成可部署的应用程序成果。在推送代码之前,开发人员应该合理地期望他们的构建会通过;因此,创建一个可靠的持续集成环境,自动化开发人员也可以在本地机器上运行的命令是至关重要的。
构建环境容器化
为了确保跨各种操作系统平台、开发者机器和持续集成环境的一致构建环境,您可以将构建环境容器化。请注意,目前至少有半打常见的持续集成工具在使用中。学习每个工具的细节几乎是一项不可能完成的任务。构建环境的容器化是一个高级概念,超出了当前持续集成工具的预期。然而,容器化是标准化您的构建基础设施的绝佳方式,几乎可以在任何持续集成环境中执行。通过这种方法,您学到的技能和创建的构建配置变得更有价值,因为您的知识和创建的工具都变得可转移和可重复使用。
有许多策略可以将构建环境容器化,具有不同的粒度和性能期望。对于本书的目的,我们将专注于可重用性和易用性。我们将专注于一个简单和直接的工作流程,而不是创建一个复杂的、相互依赖的一组 Docker 映像,这可能允许更有效的失败优先和恢复路径。较新版本的 Docker 具有一个很棒的功能,称为多阶段构建,它允许您以易于阅读的方式定义多个映像过程,并维护一个单一的Dockerfile。
在流程结束时,您可以提取一个优化的容器映像作为我们的交付成果,摆脱先前流程中使用的映像的复杂性。
作为提醒,您的单个Dockerfile将类似于下面的示例:
Dockerfile
FROM duluca/minimal-node-web-server:8.11.1
WORKDIR /usr/src/app
COPY dist public
多阶段工作是通过在单个Dockerfile中使用多个FROM语句来实现的,其中每个阶段可以执行一个任务,并使其实例内的任何资源可用于其他阶段。在构建环境中,我们可以将各种与构建相关的任务实现为它们自己的阶段,然后将最终结果,例如 Angular 构建的dist文件夹,复制到包含 Web 服务器的最终镜像中。在这种情况下,我们将实现三个阶段的镜像:
-
构建器:用于构建 Angular 应用程序的生产版本
-
测试器:用于对无头 Chrome 实例运行单元测试和端到端测试
-
Web 服务器:最终结果仅包含优化的生产位
多阶段构建需要 Docker 版本 17.05 或更高版本。要了解有关多阶段构建的更多信息,请阅读docs.docker.com/develop/develop-images/multistage-build/中的文档。
从创建一个新文件开始,以实现多阶段配置,命名为Dockerfile.integration,位于项目的根目录。
构建器
第一个阶段是构建器。我们需要一个轻量级的构建环境,可以确保一致的构建。为此,我创建了一个基于 Alpine 的 Node 构建环境示例,其中包含 npm、bash 和 git 工具。有关为什么我们使用 Alpine 和 Node 的更多信息,请参阅第三章准备 Angular 应用程序进行生产发布,使用 Docker 容器化应用程序部分。
- 实现一个新的 npm 脚本来构建你的 Angular 应用程序:
"scripts": {
"build:prod": "ng build --prod",
}
-
从基于 Node.js 的构建环境继承,如
node:10.1或duluca/minimal-node-build-env:8.11.2 -
实现你的特定环境构建脚本,如下所示:
请注意,在发布时,低级 npm 工具中的一个错误阻止了基于node的镜像成功安装 Angular 依赖项。这意味着下面的示例Dockerfile基于较旧版本的 Node 和 npm,使用了duluca/minimal-node-build-env:8.9.4。在将来,当错误得到解决时,更新的构建环境将能够利用npm ci来安装依赖项,这将比npm install命令带来显著的速度提升。
Dockerfile.integration
FROM duluca/minimal-node-build-env:8.9.4 as builder
# project variables
ENV SRC_DIR /usr/src
ENV GIT_REPO https://github.com/duluca/lemon-mart.git
ENV SRC_CODE_LOCATION .
ENV BUILD_SCRIPT build:prod
# get source code
RUN mkdir -p $SRC_DIR
WORKDIR $SRC_DIR
# if necessary, do SSH setup here or copy source code from local or CI environment
RUN git clone $GIT_REPO .
# COPY $SRC_CODE_LOCATION .
RUN npm install
RUN npm run $BUILD_SCRIPT
在上面的示例中,容器正在从 GitHub 拉取源代码。我选择这样做是为了保持示例简单,因为在本地和远程持续集成环境中它的工作方式是相同的。然而,您的持续集成服务器将已经有源代码的副本,您需要从持续集成环境中复制然后放入容器中。
您可以使用COPY $SRC_CODE_LOCATION .命令从持续集成服务器或本地计算机复制源代码,而不是使用RUN git clone $GIT_REPO .命令。如果这样做,您将需要实现一个.dockerignore文件,它与您的.gitignore文件有些相似,以确保不会泄露机密信息,不会复制node_modules,并且配置在其他环境中是可重复的。在持续集成环境中,您将需要覆盖环境变量$SRC_CODE_LOCATION,以便COPY命令的源目录是正确的。随时创建多个适合您各种需求的Dockerfile版本。
此外,我构建了一个基于node-alpine的最小 Node 构建环境duluca/minimal-node-build-env,您可以在 Docker Hub 上观察到它,网址为hub.docker.com/r/duluca/minimal-node-build-env。这个镜像比node小大约十倍。Docker 镜像的大小对构建时间有真正的影响,因为持续集成服务器或您的团队成员将花费额外的时间拉取更大的镜像。选择最适合您需求的环境。
调试构建环境
根据您的特定需求,Dockerfile的构建部分的初始设置可能会令人沮丧。为了测试新命令或调试错误,您可能需要直接与构建环境进行交互。
为了在构建环境中进行交互实验和/或调试,执行以下操作:
$ docker run -it duluca/minimal-node-build-env:8.9.4 /bin/bash
在将命令嵌入您的Dockerfile之前,您可以在此临时环境中测试或调试命令。
测试人员
第二阶段是tester。默认情况下,Angular CLI 生成了一个针对开发环境的测试要求。这在持续集成环境中不起作用;我们必须配置 Angular 以针对一个无需 GPU 辅助执行的无头浏览器,并且进一步,一个容器化环境来执行测试。
Angular 测试工具在第三章中有所涵盖,为生产发布准备 Angular 应用程序
为 Angular 配置无头浏览器
Protractor 测试工具正式支持在无头模式下运行 Chrome。为了在持续集成环境中执行 Angular 测试,您需要配置您的测试运行器 Karma 以使用无头 Chrome 实例运行:
- 更新
karma.conf.js以包括新的无头浏览器选项:
src/karma.conf.js
...
browsers: ['Chrome', 'ChromiumHeadless', 'ChromiumNoSandbox'],
customLaunchers: {
ChromiumHeadless: {
base: 'Chrome',
flags: [
'--headless',
'--disable-gpu',
// Without a remote debugging port, Google Chrome exits immediately.
'--remote-debugging-port=9222',
],
debug: true,
},
ChromiumNoSandbox: {
base: 'ChromiumHeadless',
flags: ['--no-sandbox', '--disable-translate', '--disable-extensions']
}
},
ChromiumNoSandbox自定义启动器封装了所有需要的配置元素,以便进行良好的默认设置。
- 更新
protractor配置以在无头模式下运行:
e2e/protractor.conf.js
...
capabilities: {
browserName: 'chrome',
chromeOptions: {
args: [
'--headless',
'--disable-gpu',
'--no-sandbox',
'--disable-translate',
'--disable-extensions',
'--window-size=800,600',
],
},
},
...
为了测试应用程序的响应情况,您可以使用--window-size选项,如前所示,来更改浏览器设置。
- 更新
package.json脚本以在生产构建场景中选择新的浏览器选项:
package.json
"scripts": {
...
"test:prod": "npm test -- --watch=false"
...
}
请注意,test:prod不包括npm run e2e。e2e 测试是需要更长时间执行的集成测试,因此在包含它们作为关键构建流程的一部分时要三思。e2e 测试将不会在下一节提到的轻量级测试环境中运行,因此它们将需要更多的资源和时间来执行。
配置测试环境
对于轻量级测试环境,我们将利用基于 Alpine 的 Chromium 浏览器安装:
-
继承自
slapers/alpine-node-chromium -
将以下配置附加到
Docker.integration:
Docker.integration
...
FROM slapers/alpine-node-chromium as tester
ENV BUILDER_SRC_DIR /usr/src
ENV SRC_DIR /usr/src
ENV TEST_SCRIPT test:prod
RUN mkdir -p $SRC_DIR
WORKDIR $SRC_DIR
COPY --from=builder $BUILDER_SRC_DIR $SRC_DIR
CMD 'npm run $TEST_SCRIPT'
上述脚本将从builder阶段复制生产构建,并以可预测的方式执行您的测试脚本。
Web 服务器
第三和最后阶段生成将成为您的 Web 服务器的容器。一旦完成此阶段,先前的阶段将被丢弃,最终结果将是一个优化的小于 10MB 的容器:
-
使用 Docker 将您的应用程序容器化,如第三章中所讨论的,为生产发布准备 Angular 应用程序
-
在文件末尾添加
FROM语句 -
从
builder中复制生产就绪代码,如下所示:
Docker.integration
...
FROM duluca/minimal-nginx-web-server:1.13.8-alpine
ENV BUILDER_SRC_DIR /usr/src
COPY --from=builder $BUILDER_SRC_DIR/dist /var/www
CMD 'nginx'
- 构建和测试您的多阶段
Dockerfile:
$ docker build -f Dockerfile.integration .
如果您从 GitHub 拉取代码,请确保在构建容器之前提交和推送您的代码,因为它将直接从存储库中拉取您的源代码。使用--no-cache选项确保拉取新的源代码。如果您从本地或 CI 环境复制代码,则不要使用--no-cache,因为您将无法从能够重用先前构建的容器层中获得速度提升。
- 将脚本保存为名为
build:ci的新 npm 脚本,如下所示:
package.json
"scripts": {
...
"build:ci": "docker build -f Dockerfile.integration . -t $npm_package_config_imageRepo:latest",
...
}
CircleCI
CircleCI 使得轻松开始使用免费套餐,并为初学者和专业人士提供了很好的文档。如果您有独特的企业需求,可以将 CircleCI 部署在企业内部,企业防火墙后,或作为云中的私有部署。
CircleCI 具有预先配置的构建环境,适用于免费设置的虚拟配置,但也可以使用 Docker 容器运行构建,这使得它成为一个可以根据用户技能和需求进行扩展的解决方案,正如“容器化构建环境”部分所述:
-
在
circleci.com/上创建一个 CircleCI 帐户 -
使用 GitHub 注册:
CircleCI 注册页面
- 添加新项目:
CircleCI 项目页面
在下一个屏幕上,您可以选择 Linux 或 macOS 构建环境。macOS 构建环境非常适用于构建 iOS 或 macOS 应用程序。但是,这些环境没有免费套餐;只有具有 1x 并行性的 Linux 实例是免费的。
-
搜索 lemon-mart 并点击设置项目
-
选择 Linux
-
选择平台 2.0
-
选择语言为其他,因为我们将使用自定义容器化构建环境
-
在您的源代码中,创建一个名为
.circleci的文件夹,并添加一个名为config.yml的文件:
.circleci/config.yml
version: 2
jobs:
build:
docker:
- image: docker:17.12.0-ce-git
working_directory: /usr/src
steps:
- checkout
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker Image
command: |
npm run build:ci
在前面的文件中,定义了一个build作业,它基于 CircleCI 预先构建的docker:17.12.0-ce-git镜像,其中包含 Docker 和 git CLI 工具。然后我们定义了构建“步骤”,使用checkout从 GitHub 检出源代码,通知 CircleCI 使用setup_remote_docker命令设置 Docker-within-Docker 环境,然后执行docker build -f Dockerfile.integration .命令来启动我们的自定义构建过程。
为了优化构建,您应该尝试使用层缓存和从 CircleCI 中已经检出的源代码复制源代码。
-
将更改同步到 Github
-
在 CircleCI 上,点击创建您的项目
如果一切顺利,您将获得通过的绿色构建。如下截图所示,构建#4 成功:
CircleCI 上的绿色构建
目前,CI 服务器正在运行,在第 1 阶段构建应用程序,然后在第 2 阶段运行测试,然后在第 3 阶段构建 Web 服务器。请注意,我们没有对此 Web 服务器容器映像执行任何操作,例如将其部署到服务器。
为了部署您的映像,您需要实现一个部署步骤。在此步骤中,您可以部署到多个目标,如 Docker Hub,Zeit Now,Heroku 或 AWS ECS。与这些目标的集成将涉及多个步骤。在高层次上,这些步骤如下:
-
使用单独的运行步骤安装特定于目标的 CLI 工具
-
使用特定于目标环境的登录凭据配置 Docker,并将这些凭据存储为 CircleCI 环境变量
-
使用
docker push将生成的 Web 服务器映像提交到目标的 Docker 注册表 -
执行特定于平台的
deploy命令,指示目标运行刚刚推送的 Docker 映像。
如何从本地开发环境配置 AWS ECS 上的此类部署在第十一章中有所介绍,AWS 上高可用云基础设施。
代码覆盖报告
了解您的 Angular 项目的单元测试覆盖量和趋势的一个好方法是通过代码覆盖报告。
为了为您的应用程序生成报告,请从项目文件夹中执行以下命令:
$ npx ng test --browsers ChromiumNoSandbox --watch=false --code-coverage
生成的报告将以 HTML 形式创建在名为 coverage 的文件夹下;执行以下命令在浏览器中查看:
$ npx http-server -c-1 -o -p 9875 ./coverage
这是由istanbul.js生成的 LemonMart 的文件夹级示例覆盖报告:
LemonMart 的 Istanbul 代码覆盖报告
您可以深入研究特定文件夹,例如src/app/auth,并获得文件级报告,如下所示:
Istanbul 代码覆盖报告适用于 src/app/auth
您可以进一步深入了解给定文件的行级覆盖率,例如cache.service.ts,如下所示:
cache.service.ts 的 Istanbul 代码覆盖报告
在前面的图像中,您可以看到第 5、12、17-18 和 21-22 行没有被任何测试覆盖。图标表示 if 路径未被执行。我们可以通过实现对CacheService中包含的函数进行单元测试来增加我们的代码覆盖率。作为练习,读者应该尝试至少用一个新的单元测试覆盖其中一个函数,并观察代码覆盖报告的变化。
理想情况下,您的 CI 服务器配置应该以一种方便访问的方式生成和托管代码覆盖报告,并在每次测试运行时执行。将这些命令作为package.json中的脚本实现,并在 CI 流水线中执行它们。这个配置留给读者作为一个练习。
将http-server安装为项目的开发依赖项。
API 设计
在全栈开发中,早期确定 API 设计非常重要。API 设计本身与您的数据契约的外观密切相关。您可以创建 RESTful 端点,也可以使用下一代 GraphQL 技术。在设计 API 时,前端和后端开发人员应该密切合作,以实现共享的设计目标。一些高层目标列如下:
-
最小化客户端和服务器之间传输的数据
-
坚持使用成熟的设计模式(即分页)
-
设计以减少客户端中存在的业务逻辑
-
扁平化数据结构
-
不要暴露数据库键或关系
-
从一开始就版本化端点
-
围绕主要数据组件进行设计
重要的是不要重复造轮子,并且在设计 API 时采取一种有纪律的,如果不是严格的方法是很重要的。API 设计错误的下游影响可能是深远的,一旦您的应用程序上线就无法纠正。
我将详细介绍围绕主要数据组件进行设计,并实现一个示例的 Swagger 端点。
围绕主要数据组件进行设计
围绕主要数据组件进行设计有助于组织您的 API。这将大致匹配您在 Angular 应用程序的各个组件中使用数据的方式。我们将首先通过创建一个粗略的数据实体图来定义我们的主要数据组件,然后使用 swagger 为用户数据实体实现一个示例 API。
定义实体
让我们首先尝试一下您想要存储的实体类型以及这些实体之间可能的关系。
这是一个使用draw.io创建的 LemonMart 的样本设计:
LemonMart 的数据实体图
此时,您的实体是存储在 SQL 还是 NoSQL 数据库中并不重要。我的建议是坚持你所知道的,但如果你是从零开始的,像 MongoDB 这样的 NoSQL 数据库将在您的实现和需求发展时提供最大的灵活性。
Swagger
Swagger 将允许您设计您的 Web API。对于团队来说,它可以充当前端和后端团队之间的接口。此外,通过 API 模拟,您甚至可以在 API 的实现开始之前开发和完成 API 功能。
随着我们的进展,我们将实现一个示例用户 API,以演示 Swagger 的工作原理。
示例项目附带了 VS Code 的推荐扩展。Swagger Viewer 允许我们在不运行任何其他工具的情况下预览 YAML 文件。
粗略地说,您将需要为每个实体创建 CRUD API。您可以使用 Swagger 来设计您的 API。
在components下,添加共享的parameters,使其易于重用常见模式,如分页端点:
示例代码存储库可以在github.com/duluca/lemo…找到。对于您的模拟 API 服务器,您应该创建一个单独的 git 存储库,以便前端和后端之间的这个契约可以分开维护。
-
创建一个名为
lemon-mart-swagger-server的新 GitHub 存储库 -
开始定义一个带有一般信息和目标服务器的 YAML 文件:
swagger.oas3.yaml
openapi: 3.0.0
info:
title: LemonMart
description: LemonMart API
version: "1.0.0"
servers:
- url: http://localhost:3000
description: Local environment
- url: https://mystagingserver.com/v1
description: Staging environment
- url: https://myprodserver.com/v1
description: Production environment
- Swagger 规范的最广泛使用和支持的版本是
swagger: '2.0'。下面的示例是使用更新的、基于标准的openapi: 3.0.0给出的。示例代码存储库包含了这两个示例。然而,在发布时,Swagger 生态系统中的大多数工具都依赖于 2.0 版本。在components下,定义共享数据schemas:
swagger.oas3.yaml
...
components:
schemas:
Role:
type: string
enum: [clerk, cashier, manager]
Name:
type: object
properties:
first:
type: string
middle:
type: string
last:
type: string
User:
type: object
properties:
id:
type: string
email:
type: string
name:
$ref: '#/components/schemas/Name'
picture:
type: string
role:
$ref: '#/components/schemas/Role'
userStatus:
type: boolean
lastModified:
type: string
format: date
lastModifiedBy:
type: string
Users:
type: object
properties:
total:
type: number
format: int32
items:
$ref: '#/components/schemas/ArrayOfUser'
ArrayOfUser:
type: array
items:
$ref: '#/components/schemas/User'
- 定义一个 Swagger YAML 文件
swagger.oas3.yaml
...
parameters:
offsetParam: # <-- Arbitrary name for the definition that will be used to refer to it.
# Not necessarily the same as the parameter name.
in: query
name: offset
required: false
schema:
type: integer
minimum: 0
description: The number of items to skip before starting to collect the result set.
limitParam:
in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 50
default: 20
description: The numbers of items to return.
- 在
paths下,为/users路径定义一个get端点:
...
paths:
/users:
get:
description: |
Searches and returns `User` objects.
Optional query params determines values of returned array
parameters:
- in: query
name: search
required: false
schema:
type: string
description: Search text
- $ref: '#/components/parameters/offsetParam'
- $ref: '#/components/parameters/limitParam'
responses:
'200': # Response
description: OK
content: # Response body
application/json: # Media type
schema:
$ref: '#/components/schemas/Users'
- 在
paths下,添加get通过 ID 获取用户和update通过 ID 更新用户的端点:
swagger.oas3.yaml
...
/user/{id}:
get:
description: Gets a `User` object by id
parameters:
- in: path
name: id
required: true
schema:
type: string
description: User's unique id
responses:
'200': # Response
description: OK
content: # Response body
application/json: # Media type
schema:
$ref: '#/components/schemas/User'
put:
description: Updates a `User` object given id
parameters:
- in: query
name: id
required: true
schema:
type: string
description: User's unique id
- in: body
name: userData
schema:
$ref: '#/components/schemas/User'
style: form
explode: false
description: Updated user object
responses:
'200':
description: OK
content: # Response body
application/json: # Media type
schema:
$ref: '#/components/schemas/User'
要验证你的 Swagger 文件,你可以使用在线编辑器editor.swagger.io。注意使用style: form和explode: false,这是配置期望基本表单数据的端点的最简单方式。要了解更多参数序列化选项或模拟认证端点和其他可能的配置,请参考swagger.io/docs/specif…上的文档。
创建一个 Swagger 服务器
使用你的 YAML 文件,你可以使用 Swagger Code Gen 工具生成一个模拟的 Node.js 服务器。
使用非官方工具的 OpenAPI 3.0
如前一节所述,本节将使用 YAML 文件的第 2 版,它可以使用官方工具生成服务器。然而,还有其他工具可以生成一些代码,但不完整到足够易于使用:
- 如果在项目文件夹中使用 OpenAPI 3.0,请执行以下命令:
$ npx swagger-node-codegen swagger.oas3.yaml -o ./server
...
Done!
Check out your shiny new API at C:\dev\lemon-mart-swagger-server\server.
在一个名为server的新文件夹下,你现在应该有一个生成的 Node Express 服务器。
- 为服务器安装依赖项:
$ cd server
$ npm install
然后,你必须手动实现缺失的存根来完成服务器的实现。
使用官方工具的 Swagger 2.0
使用官方工具和 2.0 版本,你可以自动创建 API 和生成响应。一旦官方工具完全支持 OpenAPI 3.0,相同的指令应该适用:
- 将你的 YAML 文件发布到一个可以被你的机器访问的 URI 上:
https://raw.githubusercontent.com/duluca/lemon-mart-swagger-server/master/swagger.2.yaml
- 在你的项目文件夹中,执行以下命令,用你的 YAML 文件指向的 URI 替换
<uri>:
$ docker run --rm -v ${PWD}:/local swaggerapi/swagger-codegen-cli
$ generate -i <uri> -l nodejs-server -o /local/server
与前一节类似,这将在服务器目录下创建一个 Node Express 服务器。为了执行这个服务器,继续以下步骤。
-
用
npm install安装服务器的依赖项。 -
运行
npm start。你的模拟服务器现在应该已经启动。 -
导航到
http://localhost:3000/docs -
尝试
get /users的 API;你会注意到 items 属性是空的:
Swagger UI - 用户端点
但是,你应该收到虚拟数据。我们将纠正这种行为。
- 尝试
get /user/{id};你会看到你收到了一些虚拟数据:
Swagger UI - 按用户 ID 端点
行为上的差异是因为,默认情况下,Node Express 服务器使用在 server/controllers/Default.js 下生成的控制器来读取在服务器创建期间从 server/service/DefaultService.js 生成的随机数据。然而,您可以禁用默认控制器,并强制 Swagger 进入更好的默认存根模式。
- 更新
index.js以强制使用存根并注释掉控制器:
index.js
var options = {
swaggerUi: path.join(__dirname, '/swagger.json'),
// controllers: path.join(__dirname, './controllers'),
useStubs: true,
}
- 再次尝试
/users端点
正如您在这里所看到的,响应默认情况下具有更高的质量:
Swagger UI - 使用虚拟数据的用户端点
在上述中,total 是一个整数,role 被正确定义,items 是一个有效的数组结构。
为了启用更好和更定制的数据模拟,您可以编辑 DefaultService.js。在这种情况下,您希望更新 usersGET 函数以返回一个定制用户数组。
启用跨域资源共享(CORS)
在您能够从应用程序中使用您的服务器之前,您需要配置它以允许跨域资源共享(CORS),以便您托管在 http://localhost:5000 上的 Angular 应用程序可以与您托管在 http://localhost:3000 上的模拟服务器进行通信:
- 安装
cors包:
$ npm i cors
- 更新
index.js以使用cors:
server/index.js
...
var cors = require('cors')
...
app.use(cors())
// Initialize the Swagger middleware
swaggerTools.initializeMiddleware(swaggerDoc, function(middleware) {
...
确保在 initializeMiddleware 之前调用 app.use(cors());否则,其他 Express 中间件可能会干扰 cors() 的功能。
验证和发布 Swagger 服务器
您可以通过 SwaggerUI 验证您的 Swagger 服务器设置,SwaggerUI 将位于 http://localhost:3000/docs,或者您可以通过 VS Code 中的预览 Swagger 扩展实现更集成的环境。
我将演示如何使用这个扩展来从 VS Code 内部测试您的 API:
-
在资源管理器中选择 YAML 文件
-
按下 Shift + Alt + P 并执行预览 Swagger 命令
-
您将看到一个交互式窗口来测试您的配置,如下所示:
在 Visual Studio Code 中预览 Swagger 扩展
-
点击 /users 的 Get 按钮
-
点击 Try it out 查看结果
在 OpenAPI 3.0.0 中,您将看到一个服务器列表,包括本地和远程资源,而不是方案。这是一个非常方便的工具,可以在编写前端应用程序时探索各种数据源。
现在您已经验证了 Swagger 服务器,您可以发布服务器,使团队成员或需要可预测数据集才能成功执行的自动验收测试(AAT)环境可以访问它。
执行以下步骤,如第三章中所述,为生产发布准备 Angular 应用程序:
-
在根级别的
package.json文件中为 Docker 添加 npm 脚本 -
添加一个
Dockerfile:
Dockerfile
FROM duluca/minimal-node-build-env:8.11.2
RUN mkdir -p /usr/src
WORKDIR /usr/src
COPY server .
RUN npm ci
CMD ["node", "index"]
构建容器后,您就可以部署它了。
我已经在 Docker Hub 上发布了一个示例服务器,网址为hub.docker.com/r/duluca/lemon-mart-swagger-server。
总结
在本章中,您学会了如何创建基于容器的持续集成环境。我们利用 CircleCI 作为基于云的 CI 服务,并强调您可以将构建结果部署到所有主要的云托管提供商。如果您启用了这样的自动化部署,您将实现持续部署(CD)。通过 CI/CD 管道,您可以与客户和团队成员分享应用程序的每个迭代,并快速向最终用户交付错误修复或新功能。
我们还讨论了良好 API 设计的重要性,并确定 Swagger 作为一个有益于前端和后端开发人员的工具,用于定义和开发针对实时数据契约的应用。如果您创建了一个 Swagger 模拟服务器,您可以让团队成员拉取模拟服务器镜像,并在后端实现完成之前使用它来开发他们的前端应用程序。
CircleCI 和 Swagger 都是各自高度复杂的工具。本章提到的技术故意简单,但旨在实现复杂的工作流程,让您领略到这些工具的真正威力。您可以大大提高这种技术的效率和能力,但这些技术将取决于您的具体需求。
装备了 CI 和模拟的 API,我们可以发送真实的 HTTP 请求,我们准备快速迭代,同时确保高质量的可交付成果。在下一章中,我们将深入探讨如何使用基于令牌的身份验证和条件导航技术,为您的业务应用程序设计授权和身份验证体验,以实现平滑的用户体验,继续采用路由器优先的方法。
第九章:设计身份验证和授权
设计高质量的身份验证和授权系统而不会让最终用户感到沮丧是一个难题。身份验证是验证用户身份的行为,授权指定用户访问资源的特权。这两个过程,简称为 auth,必须无缝地协同工作,以满足具有不同角色、需求和工作职能的用户的需求。在今天的网络中,用户对通过浏览器遇到的任何 auth 系统都有很高的期望水平,因此这是您的应用程序中绝对需要第一次就完全正确的一个非常重要的部分。
用户应始终了解他们在应用程序中可以做什么和不能做什么。如果出现错误、失败或错误,用户应清楚地了解为什么会发生这样的错误。随着应用程序的增长,很容易忽略触发错误条件的所有方式。您的实现应易于扩展或维护,否则您的应用程序的基本骨架将需要大量的维护。在本章中,我们将介绍创建出色的 auth UX 的各种挑战,并实现一个坚实的基线体验。
我们将继续采用路由器优先方法来设计 SPA,通过实现 LemonMart 的身份验证和授权体验。在第七章中,创建基于路由器的企业应用程序,我们定义了用户角色,完成了所有主要路由的构建,并完成了 LemonMart 的粗略行走骨架导航体验,因此我们已经准备好实现基于角色的路由和拉取此类实现的细微差别。
在第八章中,持续集成和 API 设计,我们讨论了围绕主要数据组件进行设计的想法,因此您已经熟悉用户实体的外观,这将在实现基于令牌的登录体验中派上用场,包括在实体内缓存角色信息。
在深入研究 auth 之前,我们将讨论在开始实现各种条件导航元素之前,完成应用程序的高级模拟的重要性,这在设计阶段可能会发生重大变化。
在本章中,您将了解以下主题:
-
高级 UX 设计的重要性
-
基于令牌的身份验证
-
条件导航
-
侧边导航栏
-
可重用的警报 UI 服务
-
缓存数据
-
JSON Web Tokens
-
Angular HTTP 拦截器
-
路由守卫
完成模型
模型在确定我们在整个应用程序中需要哪种组件和用户控件方面非常重要。任何将在组件之间使用的用户控件或组件都需要在根级别定义,其他的则在其自己的模块中定义。
在第七章,创建一个以路由为首的业务应用程序中,我们已经确定了子模块并为它们设计了着陆页面,以完成行走的骨架。现在我们已经定义了主要的数据组件,我们可以为应用程序的其余部分完成模型。在高层次设计屏幕时,请牢记几件事:
-
用户是否可以尽可能少地导航来完成其角色所需的常见任务?
-
用户是否可以通过屏幕上可见的元素轻松访问应用程序的所有信息和功能?
-
用户是否可以轻松搜索他们需要的数据?
-
一旦用户找到感兴趣的记录,他们是否可以轻松地深入了解详细记录或查看相关记录?
-
那个弹出警报真的有必要吗?您知道用户不会阅读它,对吧?
请记住,设计任何用户体验都没有一种正确的方式,这就是为什么在设计屏幕时,始终要牢记模块化和可重用性。
当您生成各种设计工件,如模型或设计决策时,请务必将它们发布在所有团队成员都可以访问的维基上:
-
在 GitHub 上,切换到 Wiki 选项卡
-
您可以查看我的示例维基,如下所示:Github.com/duluca/lemo…
GitHub.com LemonMart Wiki
-
创建维基页面时,请确保在任何其他可用文档之间进行交叉链接,例如 Readme
-
请注意,GitHub 在页面下显示维基上的子页面
-
然而,额外的摘要是有帮助的,比如设计工件部分,因为有些人可能会错过右侧的导航元素
-
完成模型后,请将其发布在维基上
您可以在这里看到维基的摘要视图:
柠檬市场模型的摘要视图
- 可选地,将模型放在行走的骨架应用程序中,以便测试人员更好地设想尚未开发的功能
完成模拟后,我们现在可以继续使用身份验证和授权工作流来实现 LemonMart。
设计认证和授权工作流
一个设计良好的身份验证工作流是无状态的,因此没有会话过期的概念。用户可以自由地与您的无状态 REST API 进行交互,无论他们希望同时或随后在多少设备和标签页上。JSON Web Token (JWT) 实现了基于分布式声明的身份验证,可以通过数字签名或集成保护和/或使用 消息认证码 (MAC) 进行加密。这意味着一旦用户的身份经过认证,比如说通过密码挑战,他们将收到一个编码的声明票据或令牌,然后可以使用它来对系统进行未来的请求,而无需重新验证用户的身份。服务器可以独立验证此声明的有效性并处理请求,而无需事先知道与该用户进行过互动。因此,我们不必存储有关用户的会话信息,使我们的解决方案无状态且易于扩展。每个令牌将在预定义的时间后过期,并且由于它们的分布式性质,无法远程或单独撤销;但是,我们可以通过插入自定义帐户和用户角色状态检查来加强实时安全性,以确保经过身份验证的用户有权访问服务器端资源。
JSON Web Tokens 实现了 IETF 行业标准 RFC7519,可以在 tools.ietf.org/html/rfc7519 找到。
良好的授权工作流程能够基于用户角色进行条件导航,以便用户自动进入最佳的登陆界面;他们不会看到不适合他们角色的路由或元素,如果他们错误地尝试访问一个授权的路径,他们将被阻止这样做。您必须记住,任何客户端角色导航仅仅是一种便利,而不是用于安全目的。这意味着每次向服务器发出的调用都应包含必要的头部信息,带有安全令牌,以便服务器可以重新验证用户,独立验证他们的角色,只有在这样做之后才允许检索安全数据。客户端身份验证是不可信的,这就是为什么密码重置屏幕必须使用服务器端渲染技术构建,以便用户和服务器都可以验证预期的用户正在与系统交互。
在接下来的部分中,我们将围绕用户数据实体设计一个完整的身份验证工作流程,如下所示:
用户实体
添加身份验证服务
我们将首先创建一个具有真实和虚假登录提供程序的身份验证服务:
- 添加身份验证和授权服务:
$ npx ng g s auth -m app --flat false
- 确保服务在
app.module中提供:
src/app/app.module.ts
import { AuthService } from './auth/auth.service'
...
providers: [AuthService],
为服务创建一个单独的文件夹将组织各种与身份验证和授权相关的组件,例如Role的enum定义。此外,我们还将能够在同一个文件夹中添加一个authService的伪造版本,这对于编写单元测试至关重要。
- 将用户角色定义为
enum:
src/app/auth/role.enum.ts
export enum Role {
None = 'none',
Clerk = 'clerk',
Cashier = 'cashier',
Manager = 'manager',
}
实现基本的身份验证服务
现在,让我们构建一个本地身份验证服务,这将使我们能够演示一个强大的登录表单、缓存和基于身份验证状态和用户角色的条件导航概念:
- 首先安装一个 JWT 解码库,以及一个用于伪造身份验证的 JWT 编码库:
$ npm install jwt-decode fake-jwt-sign
$ npm install -D @types/jwt-decode
- 为
auth.service.ts定义导入项:
src/app/auth/auth.service.ts
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { sign } from 'fake-jwt-sign' // For fakeAuthProvider only
import * as decode from 'jwt-decode'
import { BehaviorSubject, Observable, of, throwError as observableThrowError } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { environment } from '../../environments/environment'
import { Role } from './role.enum'
...
- 实现
IAuthStatus接口来存储解码后的用户信息,一个辅助接口,以及默认安全的defaultAuthStatus:
src/app/auth/auth.service.ts
...
export interface IAuthStatus {
isAuthenticated: boolean
userRole: Role
userId: string
}
interface IServerAuthResponse {
accessToken: string
}
const defaultAuthStatus = { isAuthenticated: false, userRole: Role.None, userId: null }
...
IAuthUser是一个接口,代表了您可能从身份验证服务接收到的典型 JWT 的形状。它包含有关用户及其角色的最少信息,因此可以附加到服务器调用的header中,并且可以选择地缓存在localStorage中以记住用户的登录状态。在前面的实现中,我们假设了Manager的默认角色。
- 使用
BehaviorSubject定义AuthService类来锚定用户当前的authStatus,并在构造函数中配置一个authProvider,该authProvider可以处理email和password并返回一个IServerAuthResponse:
src/app/auth/auth.service.ts ...
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly authProvider: (
email: string,
password: string
) => Observable<IServerAuthResponse>
authStatus = new BehaviorSubject<IAuthStatus>(defaultAuthStatus)
constructor(private httpClient: HttpClient) {
// Fake login function to simulate roles
this.authProvider = this.fakeAuthProvider
// Example of a real login call to server-side
// this.authProvider = this.exampleAuthProvider
}
...
请注意,fakeAuthProvider被配置为该服务的authProvider。真实的身份验证提供程序可能看起来像以下代码,其中用户的电子邮件和密码被发送到一个 POST 端点,该端点验证他们的信息,创建并返回一个 JWT 供我们的应用程序使用:
example
private exampleAuthProvider(
email: string,
password: string
): Observable<IServerAuthResponse> {
return this.httpClient.post<IServerAuthResponse>(`${environment.baseUrl}/v1/login`, {
email: email,
password: password,
})
}
这很简单,因为大部分工作是在服务器端完成的。这个调用也可以发送给第三方。
请注意,URL 路径中的 API 版本v1是在服务中定义的,而不是作为baseUrl的一部分。这是因为每个 API 可以独立于其他 API 更改版本。登录可能长时间保持为v1,而其他 API 可能升级为v2、v3等。
- 实现一个
fakeAuthProvider,模拟身份验证过程,包括动态创建一个假的 JWT:
src/app/auth/auth.service.ts
...
private fakeAuthProvider(
email: string,
password: string
): Observable<IServerAuthResponse> {
if (!email.toLowerCase().endsWith('@test.com')) {
return observableThrowError('Failed to login! Email needs to end with @test.com.')
}
const authStatus = {
isAuthenticated: true,
userId: 'e4d1bc2ab25c',
userRole: email.toLowerCase().includes('cashier')
? Role.Cashier
: email.toLowerCase().includes('clerk')
? Role.Clerk
: email.toLowerCase().includes('manager') ? Role.Manager : Role.None,
} as IAuthStatus
const authResponse = {
accessToken: sign(authStatus, 'secret', {
expiresIn: '1h',
algorithm: 'none',
}),
} as IServerAuthResponse
return of(authResponse)
}
...
fakeAuthProvider在服务中实现了本来应该是服务器端方法,因此您可以方便地在微调身份验证工作流程的同时实验代码。它使用临时的fake-jwt-sign库创建并签署了一个 JWT,以便我们还可以演示如何处理一个格式正确的 JWT。
不要将您的 Angular 应用程序与fake-jwt-sign依赖项一起发布,因为它是用于服务器端代码的。
- 在我们继续之前,实现一个
transformError函数来处理在common/common.ts下的可观察流中混合的HttpErrorResponse和字符串错误:
src/app/common/common.ts
import { HttpErrorResponse } from '@angular/common/http'
import { throwError } from 'rxjs'
export function transformError(error: HttpErrorResponse | string) {
let errorMessage = 'An unknown error has occurred'
if (typeof error === 'string') {
errorMessage = error
} else if (error.error instanceof ErrorEvent) {
errorMessage = `Error! ${error.error.message}`
} else if (error.status) {
errorMessage = `Request failed with ${error.status} ${error.statusText}`
}
return throwError(errorMessage)
}
-
实现
login函数,该函数将从LoginComponent中调用,如下一节所示 -
添加
import { transformError } from '../common/common' -
还要实现一个相应的
logout函数,可以由顶部工具栏中的注销按钮调用,也可以由登录尝试失败或者如果路由器身份验证守卫检测到未经授权的访问尝试时调用,这是本章后面涵盖的一个主题:
src/app/auth/auth.service.ts
...
login(email: string, password: string): Observable<IAuthStatus> {
this.logout()
const loginResponse = this.authProvider(email, password).pipe(
map(value => {
return decode(value.accessToken) as IAuthStatus
}),
catchError(transformError)
)
loginResponse.subscribe(
res => {
this.authStatus.next(res)
},
err => {
this.logout()
return observableThrowError(err)
}
)
return loginResponse
}
logout() {
this.authStatus.next(defaultAuthStatus)
}
}
login方法通过调用logout方法,authProvider与email和password信息,并在必要时抛出错误来封装正确的操作顺序。
login方法遵循 SOLID 设计中的开闭原则,通过对外部提供不同的 auth 提供程序来扩展,但对修改保持封闭,因为功能的差异被封装在 auth 提供程序中。
在下一节中,我们将实现LoginComponent,以便用户可以输入他们的用户名和密码信息并尝试登录。
实现登录组件
login组件利用我们刚刚创建的authService并使用响应式表单实现验证错误。登录组件应该以一种独立于任何其他组件的方式进行设计,因为在路由事件期间,如果我们发现用户没有得到适当的身份验证或授权,我们将把他们导航到这个组件。我们可以将这个起源 URL 捕获为redirectUrl,这样一旦用户成功登录,我们就可以将他们导航回去。
- 让我们从实现到
login组件的路由开始:
src/app/app-routing.modules.ts
...
{ path: 'login', component: LoginComponent },
{ path: 'login/:redirectUrl', component: LoginComponent },
...
- 现在实现组件本身:
src/app/login/login.component.ts
import { Component, OnInit } from '@angular/core'
import { FormBuilder, FormGroup, Validators, NgForm } from '@angular/forms'
import { AuthService } from '../auth/auth.service'
import { Role } from '../auth/role.enum'
@Component({
selector: 'app-login',
templateUrl: 'login.component.html',
styles: [
`
.error {
color: red
}
`,
`
div[fxLayout] {margin-top: 32px;}
`,
],
})
export class LoginComponent implements OnInit {
loginForm: FormGroup
loginError = ''
redirectUrl
constructor(
private formBuilder: FormBuilder,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute
) {
route.paramMap.subscribe(params => (this.redirectUrl = params.get('redirectUrl')))
}
ngOnInit() {
this.buildLoginForm()
}
buildLoginForm() {
this.loginForm = this.formBuilder.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
]],
})
}
async login(submittedForm: FormGroup) {
this.authService
.login(submittedForm.value.email, submittedForm.value.password)
.subscribe(authStatus => {
if (authStatus.isAuthenticated) {
this.router.navigate([this.redirectUrl || '/manager'])
}
}, error => (this.loginError = error))
}
}
作为成功登录尝试的结果,我们利用路由器将经过身份验证的用户导航到他们的个人资料。在通过服务从服务器发送的错误的情况下,我们将将该错误分配给loginError。
- 这里是一个用于捕获和验证用户的
email和password的登录表单的实现,并且如果有任何服务器错误,显示它们:
src/app/login/login.component.html
<div fxLayout="row" fxLayoutAlign="center">
<mat-card fxFlex="400px">
<mat-card-header>
<mat-card-title>
<div class="mat-headline">Hello, Lemonite!</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="loginForm" (ngSubmit)="login(loginForm)" fxLayout="column">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
<mat-icon>email</mat-icon>
<mat-form-field fxFlex>
<input matInput placeholder="E-mail" aria-label="E-mail" formControlName="email">
<mat-error *ngIf="loginForm.get('email').hasError('required')">
E-mail is required
</mat-error>
<mat-error *ngIf="loginForm.get('email').hasError('email')">
E-mail is not valid
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
<mat-icon matPrefix>vpn_key</mat-icon>
<mat-form-field fxFlex>
<input matInput placeholder="Password" aria-label="Password" type="password" formControlName="password">
<mat-hint>Minimum 8 characters</mat-hint>
<mat-error *ngIf="loginForm.get('password').hasError('required')">
Password is required
</mat-error>
<mat-error *ngIf="loginForm.get('password').hasError('minlength')">
Password is at least 8 characters long
</mat-error>
<mat-error *ngIf="loginForm.get('password').hasError('maxlength')">
Password cannot be longer than 50 characters
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" class="margin-top">
<div *ngIf="loginError" class="mat-caption error">{{loginError}}</div>
<div class="flex-spacer"></div>
<button mat-raised-button type="submit" color="primary" [disabled]="loginForm.invalid">Login</button>
</div>
</form>
</mat-card-content>
</mat-card>
</div>
登录按钮在满足客户端验证规则之前将被禁用。此外,<mat-form-field>一次只会显示一个mat-error,除非您为更多错误创建更多的空间,所以请确保将您的错误条件放在正确的顺序中。
一旦您完成了实现login组件,现在可以更新主屏幕以有条件地显示或隐藏我们创建的新组件。
- 更新
home.component以在用户打开应用程序时显示登录:
src/app/home/home.component.ts
template: `
<div *ngIf="displayLogin">
<app-login></app-login>
</div>
<div *ngIf="!displayLogin">
<span class="mat-display-3">You get a lemon, you get a lemon, you get a lemon...</span>
</div>
`,
export class HomeComponent implements OnInit {
displayLogin = true
...
不要忘记将上面的代码中所需的依赖模块导入到您的 Angular 应用程序中。有意留给读者去找到并导入缺失的模块。
你的应用程序应该看起来类似于这个屏幕截图:
带有登录的 LemonMart
在实现和显示/隐藏侧边栏菜单、个人资料和注销图标方面,还有一些工作要做,这取决于用户的认证状态。
有条件的导航
有条件的导航在创建一个无挫折的用户体验方面是必要的。通过选择性地显示用户可以访问的元素并隐藏他们无法访问的元素,我们允许用户自信地浏览应用程序。
让我们从在用户登录到应用程序后隐藏登录组件开始:
-
在
home组件中,导入authService到home.component -
将
authStatus设置为名为displayLogin的本地变量:
src/app/home/home.component
...
import { AuthService } from '../auth/auth.service'
...
export class HomeComponent implements OnInit {
private _displayLogin = true
constructor(private authService: AuthService) {}
ngOnInit() {
this.authService.authStatus.subscribe(
authStatus => (this._displayLogin = !authStatus.isAuthenticated)
)
}
get displayLogin() {
return this._displayLogin
}
}
这里需要一个displayLogin的属性获取器,否则您可能会收到一个Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked的错误消息。这个错误是 Angular 组件生命周期和变化检测工作方式的副作用。这种行为很可能会在未来的 Angular 版本中发生变化。
- 在
app组件上,订阅认证状态并将当前值存储在名为displayAccountIcons的本地变量中:
src/app/app.component.ts
import { Component, OnInit } from '@angular/core'
import { AuthService } from './auth/auth.service'
...
export class AppComponent implements OnInit {
displayAccountIcons = false
constructor(..., private authService: AuthService) {
...
ngOnInit() {
this.authService.authStatus.subscribe(
authStatus => (this.displayAccountIcons = authStatus.isAuthenticated)
)
}
...
}
- 使用
*ngIf来隐藏所有针对已登录用户的按钮:
src/app/app.component.ts
<button *ngIf="displayAccountIcons" ... >
现在,当用户登出时,您的工具栏应该看起来干净整洁,没有任何按钮,如下所示:
登录后的 LemonMart 工具栏
常见验证
在我们继续之前,我们需要为loginForm实现验证。当我们在第十章中实现更多表单时,您会意识到在模板或响应式表单中重复输入表单验证会变得很繁琐。响应式表单的吸引力之一是它由代码驱动,因此我们可以轻松地将验证提取到一个共享类中,进行单元测试,并重复使用它们:
-
在
common文件夹下创建一个validations.ts文件 -
实现电子邮件和密码验证:
src/app/common/validations.ts
import { Validators } from '@angular/forms'
export const EmailValidation = [Validators.required, Validators.email]
export const PasswordValidation = [
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
]
根据您的密码验证需求,您可以使用RegEx模式和Validations.pattern()函数来强制密码复杂性规则,或者利用 OWASP npm 包owasp-password-strength-test来启用密码短语以及设置更灵活的密码要求。
- 使用新的验证更新
login组件:
src/app/login/login.component.ts
import { EmailValidation, PasswordValidation } from '../common/validations'
...
this.loginForm = this.formBuilder.group({
email: ['', EmailValidation],
password: ['', PasswordValidation],
})
UI 服务
当我们开始处理复杂的工作流程,比如身份验证工作流时,能够以编程方式为用户显示一个提示通知是很重要的。在其他情况下,我们可能希望在执行破坏性操作之前要求确认,这时需要一个更具侵入性的弹出通知。
无论您使用哪个组件库,都会变得很烦琐,因为您需要重复编写相同的样板代码,只是为了显示一个快速通知。UI 服务可以整洁地封装一个默认实现,也可以根据需要进行自定义:
-
在
common下创建一个新的uiService -
实现一个
showToast函数:
src/app/common/ui.service.ts
import { Injectable, Component, Inject } from '@angular/core'
import {
MatSnackBar,
MatSnackBarConfig,
MatDialog,
MatDialogConfig,
} from '@angular/material'
import { Observable } from 'rxjs'
@Injectable()
export class UiService {
constructor(private snackBar: MatSnackBar, private dialog: MatDialog) {}
showToast(message: string, action = 'Close', config?: MatSnackBarConfig) {
this.snackBar.open(
message,
action,
config || {
duration: 7000,
}
)
}
...
}
对于showDialog函数,我们必须实现一个基本的对话框组件:
- 在
app.module提供的common文件夹下添加一个新的simpleDialog,包括内联模板和样式
app/common/simple-dialog/simple-dialog.component.ts
@Component({
template: `
<h2 mat-dialog-title>data.title</h2>
<mat-dialog-content>
<p>data.content</p>
</mat-dialog-content>
<mat-dialog-actions>
<span class="flex-spacer"></span>
<button mat-button mat-dialog-close *ngIf="data.cancelText">data.cancelText</button>
<button mat-button mat-button-raised color="primary" [mat-dialog-close]="true"
cdkFocusInitial>
data.okText
</button>
</mat-dialog-actions>
`,
})
export class SimpleDialogComponent {
constructor(
public dialogRef: MatDialogRef<SimpleDialogComponent, Boolean>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
}
请注意,SimpleDialogComponent不应该像selector: 'app-simple-dialog'那样具有应用程序选择器,因为我们只打算与UiService一起使用它。从组件中删除此属性。
- 然后,实现一个
showDialog函数来显示SimpleDialogComponent:
app/common/ui.service.ts
...
showDialog(
title: string,
content: string,
okText = 'OK',
cancelText?: string,
customConfig?: MatDialogConfig
): Observable<Boolean> {
const dialogRef = this.dialog.open(
SimpleDialogComponent,
customConfig || {
width: '300px',
data: { title: title, content: content, okText: okText, cancelText: cancelText },
}
)
return dialogRef.afterClosed()
}
}
ShowDialog返回一个Observable<boolean>,因此您可以根据用户的选择实施后续操作。单击“确定”将返回true,单击“取消”将返回false。
在SimpleDialogComponent中,使用@Inject,我们可以使用showDialog发送的所有变量来自定义对话框的内容。
不要忘记更新app.module.ts和material.module.ts,引入各种新的依赖项。
- 更新
login组件以在登录后显示一个提示消息:
src/app/login/login.component.ts
import { UiService } from '../common/ui.service'
...
constructor(... ,
private uiService: UiService)
...
.subscribe(authStatus => {
if (authStatus.isAuthenticated) {
this.uiService.showToast(`Welcome! Role: ${authStatus.userRole}`)
...
用户登录后将显示一个提示消息,如下所示:
Material Snack bar
snackBar将根据浏览器的大小占据整个屏幕或部分屏幕。
使用 Cookie 和本地存储进行缓存
我们必须能够缓存已登录用户的身份验证状态。否则,每次刷新页面,用户都必须通过登录流程。我们需要更新AuthService以便持久保存身份验证状态。
有三种主要的数据存储方式:
-
Cookie
-
本地存储
-
会话存储
不应该使用 Cookie 来存储安全数据,因为它们可能会被不良行为者嗅探或窃取。此外,Cookie 最多可以存储 4KB 的数据,并且可以设置过期时间。
localStorage 和 sessionStorage 在某种程度上是相似的。它们是受保护和隔离的浏览器端存储,允许存储大量应用程序数据。你不能在这两个存储上设置过期时间。当浏览器窗口关闭时,sessionStorage 的值会被移除。这些值会在页面重新加载和恢复时保留。
JSON Web Token 是加密的,并包括一个用于过期的时间戳,从本质上来说,它抵消了 cookie 和 localStorage 的弱点。任何选项都应该与 JWT 一起使用是安全的。
让我们首先实现一个缓存服务,可以将我们的身份验证信息的缓存方法抽象出来,AuthService 可以使用。
- 首先创建一个抽象的
cacheService,封装缓存方法:
src/app/auth/cache.service.ts
export abstract class CacheService {
protected getItem<T>(key: string): T {
const data = localStorage.getItem(key)
if (data && data !== 'undefined') {
return JSON.parse(data)
}
return null
}
protected setItem(key: string, data: object | string) {
if (typeof data === 'string') {
localStorage.setItem(key, data)
}
localStorage.setItem(key, JSON.stringify(data))
}
protected removeItem(key: string) {
localStorage.removeItem(key)
}
protected clear() {
localStorage.clear()
}
}
这个缓存服务基类可以用来赋予任何服务缓存功能。这与创建一个注入到其他服务中的集中式缓存服务不同。通过避免集中式值存储,我们避免了各种服务之间的相互依赖。
- 更新
AuthService以扩展CacheService并实现authStatus的缓存:
auth/auth.service
...
export class AuthService extends CacheService {
authStatus = new BehaviorSubject<IAuthStatus>(
this.getItem('authStatus') || defaultAuthStatus
)
constructor(private httpClient: HttpClient) {
super()
this.authStatus.subscribe(authStatus => this.setItem('authStatus', authStatus))
...
}
...
}
这里演示的技术可以用来持久化任何类型的数据,并有意地利用 RxJS 事件来更新缓存。正如你可能注意到的,我们不需要更新登录函数来调用 setItem,因为它已经调用了 this.authStatus.next,我们只是连接到数据流。这有助于保持无状态和避免副作用,通过将函数解耦。
在初始化 BehaviorSubject 时,要注意处理从缓存加载数据时的 undefined/null 情况,并提供默认实现。你可以在 setItem 和 getItem 函数中实现自己的自定义缓存过期方案,或者利用第三方创建的服务。
如果你正在开发一个高安全性的应用程序,你可能选择只缓存 JWT 以确保额外的安全层。在任何情况下,JWT 应该被单独缓存,因为令牌必须在每个请求的标头中发送到服务器。了解基于令牌的身份验证如何工作是很重要的,以避免泄露妥协的秘密。在下一节中,我们将介绍 JWT 的生命周期,以提高你的理解。
JSON Web Token 生命周期
JSON Web Tokens 与基于状态的 REST API 架构相辅相成,具有加密令牌机制,允许方便、分布式和高性能的客户端请求的身份验证和授权。令牌身份验证方案有三个主要组件:
-
客户端,捕获登录信息并隐藏不允许的操作,以获得良好的用户体验
-
服务器端,验证每个请求既经过身份验证又具有适当的授权
-
Auth 服务,生成和验证加密令牌,独立验证用户请求的身份验证和授权状态,从数据存储中验证
安全系统假定在主要组件之间发送/接收的数据是在传输中加密的。这意味着您的 REST API 必须使用正确配置的 SSL 证书托管,通过 HTTPS 提供所有 API 调用,以便用户凭据在客户端和服务器之间永远不会暴露。同样,任何数据库或第三方服务调用都应该通过 HTTPS 进行。此外,存储密码的任何数据存储应该使用安全的单向哈希算法和良好的盐化实践。任何其他敏感用户信息应该使用安全的双向加密算法在静止状态下进行加密。遵循这种分层安全方法至关重要,因为攻击者需要同时攻破所有实施的安全层,才能对您的业务造成实质性的伤害。
下一个序列图突出了基于 JWT 的身份验证的生命周期:
基于 JWT 的身份验证的生命周期
最初,用户通过提供其用户名和密码登录。一旦验证,用户的身份验证状态和角色将被加密为具有过期日期和时间的 JWT,并发送回浏览器。
您的 Angular(或任何其他 SPA)应用程序可以安全地将此令牌缓存在本地或会话存储中,以便用户不必在每个请求中登录,或者更糟糕的是,我们不会在浏览器中存储用户凭据。让我们更新身份验证服务,以便它可以缓存令牌。
- 更新服务以能够设置、获取、解码和清除令牌,如下所示:
src/app/auth/auth.service.ts
...
private setToken(jwt: string) {
this.setItem('jwt', jwt)
}
private getDecodedToken(): IAuthStatus {
return decode(this.getItem('jwt'))
}
getToken(): string {
return this.getItem('jwt') || ''
}
private clearToken() {
this.removeItem('jwt')
}
- 在登录期间调用
setToken,在注销期间调用clearToken,如下所示:
src/app/auth/auth.service.ts
...
login(email: string, password: string): Observable<IAuthStatus> {
this.logout()
const loginResponse = this.authProvider(email, password).pipe(
map(value => {
this.setToken(value.accessToken)
return decode(value.accessToken) as IAuthStatus
}),
catchError(transformError)
)
...
logout() {
this.clearToken()
this.authStatus.next(defaultAuthStatus)
}
每个后续请求都将在请求头中包含 JWT。您应该保护每个 API 以检查并验证收到的令牌。例如,如果用户想要访问他们的个人资料,AuthService将验证令牌以检查用户是否经过身份验证,但还需要进一步的数据库调用来检查用户是否有权查看数据。这确保了对用户对系统的访问的独立确认,并防止对未过期令牌的滥用。
如果经过身份验证的用户调用 API,但他们没有适当的授权,比如说一个职员想要访问所有用户的列表,那么AuthService将返回一个虚假的状态,客户端将收到 403 Forbidden 的响应,这将显示为用户的错误消息。
用户可以使用过期的令牌发出请求;当这种情况发生时,将向客户端发送 401 未经授权的响应。作为良好的用户体验实践,我们应该自动提示用户重新登录,并让他们在没有任何数据丢失的情况下恢复他们的工作流程。
总之,真正的安全性是通过强大的服务器端实现来实现的,任何客户端实现主要是为了实现良好的安全实践周围的良好用户体验。
HTTP 拦截器
实现 HTTP 拦截器以将 JWT 注入到发送给用户的每个请求的头部,并通过要求用户登录来优雅地处理身份验证失败:
- 在
auth下创建authHttpInterceptor:
src/app/auth/auth-http-interceptor.ts
import {
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { Observable, throwError as observableThrowError } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { AuthService } from './auth.service'
@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
constructor(private authService: AuthService, private router: Router) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const jwt = this.authService.getToken()
const authRequest = req.clone({ setHeaders: { authorization: `Bearer ${jwt}` } })
return next.handle(authRequest).pipe(
catchError((err, caught) => {
if (err.status === 401) {
this.router.navigate(['/user/login'], {
queryParams: { redirectUrl: this.router.routerState.snapshot.url },
})
}
return observableThrowError(err)
})
)
}
}
请注意,AuthService被利用来检索令牌,并且在 401 错误后为登录组件设置redirectUrl。
- 更新
app模块以提供拦截器:
src/app/app.module.ts
providers: [
...
{
provide: HTTP_INTERCEPTORS,
useClass: AuthHttpInterceptor,
multi: true,
},
],
您可以在 Chrome Dev Tools | Network 选项卡中观察拦截器的操作,当应用程序正在获取lemon.svg文件时:
lemon.svg 的请求头
侧边导航
启用移动优先工作流,并提供一个简单的导航机制,以便快速跳转到所需的功能。使用身份验证服务,根据用户当前的角色,只显示他们可以访问的功能链接。我们将按照以下方式实现侧边导航的模拟:
侧边导航的模拟
让我们将侧边导航的代码实现为一个单独的组件,以便更容易维护:
- 在
app.module中创建和声明NavigationMenuComponent。
src/app/app.module.ts
@NgModule({
declarations: [
...
NavigationMenuComponent,
],
在用户登录之后,其实并不需要侧边导航。但是,为了能够从工具栏启动侧边导航菜单,我们需要能够从app.component触发它。由于这个组件很简单,我们将急切加载它。要惰性加载它,Angular 确实有一个动态组件加载器模式,但这种模式的实现开销很大,只有在节省数百千字节的情况下才有意义。
SideNav将从工具栏触发,并且它带有一个<mat-sidenav-container>父容器,其中包含SideNav本身和应用程序的内容。因此,我们需要通过将<router-outlet>放置在<mat-sidenav-content>中来渲染所有应用程序内容。
- 在 material.module 中导入
MatSidenavModule和MatListModule
src/app/material.module.ts
@NgModule({
imports: [
...
MatSidenavModule,
MatListModule,
],
exports: [
...
MatSidenavModule,
MatListModule,
]
- 定义一些样式,以确保 Web 应用程序将扩展到填满整个页面,并在桌面和移动设备情况下保持正确的可滚动性:
src/app/app.component.ts
styles: [
`.app-container {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.app-is-mobile .app-toolbar {
position: fixed;
z-index: 2;
}
.app-sidenav-container {
flex: 1;
}
.app-is-mobile .app-sidenav-container {
flex: 1 0 auto;
},
mat-sidenav {
width: 200px;
}
`
],
- 在
AppComponent中导入ObservableMedia服务:
src/app/app.component.ts
constructor(
...
public media: ObservableMedia
) {
...
}
- 使用响应式
SideNav更新模板,该模板将在移动设备上滑动到内容上方,或者在桌面情况下将内容推到一边:
src/app/app.component.ts
...
template: `
<div class="app-container">
<mat-toolbar color="primary" fxLayoutGap="8px" class="app-toolbar"
[class.app-is-mobile]="media.isActive('xs')">
<button *ngIf="displayAccountIcons" mat-icon-button (click)="sidenav.toggle()">
<mat-icon>menu</mat-icon>
</button>
<a mat-icon-button routerLink="/home">
<mat-icon svgIcon="lemon"></mat-icon><span class="mat-h2">LemonMart</span>
</a>
<span class="flex-spacer"></span>
<button *ngIf="displayAccountIcons" mat-mini-fab routerLink="/user/profile"
matTooltip="Profile" aria-label="User Profile"><mat-icon>account_circle</mat-icon>
</button>
<button *ngIf="displayAccountIcons" mat-mini-fab routerLink="/user/logout"
matTooltip="Logout" aria-label="Logout"><mat-icon>lock_open</mat-icon>
</button>
</mat-toolbar>
<mat-sidenav-container class="app-sidenav-container"
[style.marginTop.px]="media.isActive('xs') ? 56 : 0">
<mat-sidenav #sidenav [mode]="media.isActive('xs') ? 'over' : 'side'"
[fixedInViewport]="media.isActive('xs')" fixedTopGap="56">
<app-navigation-menu></app-navigation-menu>
</mat-sidenav>
<mat-sidenav-content>
<router-outlet class="app-container"></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</div>
`,
前面的模板利用了早期注入的 Angular Flex 布局媒体可观察对象,实现了响应式实现。
由于将显示在SiveNav内的链接长度不定,并且受到各种基于角色的业务规则的影响,最好将其实现为一个单独的组件。
- 为
displayAccountIcons实现一个属性获取器,并使用setTimeout来避免出现ExpressionChangedAfterItHasBeenCheckedError等错误
src/app/app.component.ts export class AppComponent implements OnInit {
_displayAccountIcons = false
...
ngOnInit() {
this.authService.authStatus.subscribe(authStatus => {
setTimeout(() => {
this._displayAccountIcons = authStatus.isAuthenticated
}, 0)
})
}
get displayAccountIcons() {
return this._displayAccountIcons
}
}
- 在
NavigationMenuComponent中实现导航链接:
src/app/navigation-menu/navigation-menu.component.ts
...
styles: [
`
.active-link {
font-weight: bold;
border-left: 3px solid green;
}
`,
],
template: `
<mat-nav-list>
<h3 matSubheader>Manager</h3>
<a mat-list-item routerLinkActive="active-link" routerLink="/manager/users">Users</a>
<a mat-list-item routerLinkActive="active-link" routerLink="/manager/receipts">Receipts</a>
<h3 matSubheader>Inventory</h3>
<a mat-list-item routerLinkActive="active-link" routerLink="/inventory/stockEntry">Stock Entry</a>
<a mat-list-item routerLinkActive="active-link" routerLink="/inventory/products">Products</a>
<a mat-list-item routerLinkActive="active-link" routerLink="/inventory/categories">Categories</a>
<h3 matSubheader>Clerk</h3>
<a mat-list-item routerLinkActive="active-link" routerLink="/pos">POS</a>
</mat-nav-list>
`,
...
<mat-nav-list>在功能上等同于<mat-list>,因此您可以使用该组件的文档进行布局目的。观察这里的经理、库存和职员的子标题:
在桌面上显示的经理仪表板上的收据查找
routerLinkActive="active-link"突出显示所选的收据路由,如前面的屏幕截图所示。
此外,您可以在移动设备上看到外观和行为的差异如下:
在移动设备上显示的经理仪表板上的收据查找
登出
现在我们正在缓存登录状态,我们需要实现一个登出体验:
- 在
AuthService中实现logout函数:
src/app/auth/auth.service.ts
...
logout() {
this.clearToken()
this.authStatus.next(defaultAuthStatus)
}
- 实现
logout组件:
src/app/user/logout/logout.component.ts
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService } from '../../auth/auth.service'
@Component({
selector: 'app-logout',
template: `
<p>
Logging out...
</p>
`,
styles: [],
})
export class LogoutComponent implements OnInit {
constructor(private router: Router, private authService: AuthService) {}
ngOnInit() {
this.authService.logout()
this.router.navigate(['/'])
}
}
正如您所注意到的,注销后,用户将被导航回到主页。
登录后基于角色的路由
这是您应用程序的最基本和重要的部分。通过延迟加载,我们确保只加载了最少量的资产,以使用户能够登录。
用户一旦登录,他们应该根据其用户角色被路由到适当的登陆屏幕,这样他们就不会猜测他们需要如何使用应用程序。例如,收银员只需要访问 POS 来结账,所以他们可以自动路由到该屏幕。
您可以找到 POS 屏幕的模拟如下所示:
销售点屏幕模拟让我们通过更新
LoginComponent来确保用户在登录后被路由到适当的页面:
- 更新
login逻辑以根据角色路由:
app/src/login/login.component.ts
async login(submittedForm: FormGroup) {
...
this.router.navigate([
this.redirectUrl || this.homeRoutePerRole(authStatus.userRole)
])
...
}
homeRoutePerRole(role: Role) {
switch (role) {
case Role.Cashier:
return '/pos'
case Role.Clerk:
return '/inventory'
case Role.Manager:
return '/manager'
default:
return '/user/profile'
}
}
同样,职员和经理被路由到他们的登陆屏幕,以便访问他们需要完成任务的功能,就像之前展示的那样。由于我们实现了默认的经理角色,相应的登陆体验将自动启动。另一面是有意和无意地尝试访问用户无权访问的路由。在下一节中,我们将学习关于路由守卫,它可以在表单呈现之前帮助检查身份验证甚至加载必要的数据。
路由守卫
路由守卫使逻辑进一步解耦和重用,并对组件生命周期有更大的控制。
以下是您最有可能使用的四个主要守卫:
-
CanActivate和CanActivateChild,用于检查对路由的授权访问 -
CanDeactivate,用于在离开路由之前请求权限 -
Resolve,允许从路由参数预取数据 -
CanLoad,允许在加载功能模块资产之前执行自定义逻辑
有关如何利用CanActivate和CanLoad,请参考以下章节。Resolve守卫将在第十章 Angular App Design and Recipes中介绍。
身份验证守卫
身份验证守卫通过允许或禁止在加载任何数据请求到服务器之前意外导航到功能模块或组件,从而实现良好的用户体验。
例如,当经理登录时,他们会自动路由到/manager/home路径。浏览器将缓存此 URL,对于一个职员意外导航到相同的 URL 是完全合理的。Angular 不知道特定路由对用户是否可访问,没有AuthGuard,它将愉快地渲染经理的主页并触发最终失败的服务器请求。
不管你的前端实现有多健壮,你实现的每个 REST API 都应该在服务器端得到适当的保护。让我们更新路由器,这样ProfileComponent在没有经过身份验证的用户的情况下就无法激活,而ManagerModule在没有经过经理使用AuthGuard登录的情况下就不会加载:
- 实现一个
AuthGuard服务:
src/app/auth/auth-guard.service.ts
import { Injectable } from '@angular/core'
import {
CanActivate,
Router,
ActivatedRouteSnapshot,
RouterStateSnapshot,
CanLoad,
CanActivateChild,
} from '@angular/router'
import { AuthService, IAuthStatus } from './auth.service'
import { Observable } from 'rxjs'
import { Route } from '@angular/compiler/src/core'
import { Role } from './role.enum'
import { UiService } from '../common/ui.service'
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
protected currentAuthStatus: IAuthStatus
constructor(
protected authService: AuthService,
protected router: Router,
private uiService: UiService
) {
this.authService.authStatus.subscribe(
authStatus => (this.currentAuthStatus = authStatus)
)
}
canLoad(route: Route): boolean | Observable<boolean> | Promise<boolean> {
return this.checkLogin()
}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | Observable<boolean> | Promise<boolean> {
return this.checkLogin(route)
}
canActivateChild(
childRoute: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | Observable<boolean> | Promise<boolean> {
return this.checkLogin(childRoute)
}
protected checkLogin(route?: ActivatedRouteSnapshot) {
let roleMatch = true
let params: any
if (route) {
const expectedRole = route.data.expectedRole
if (expectedRole) {
roleMatch = this.currentAuthStatus.userRole === expectedRole
}
if (roleMatch) {
params = { redirectUrl: route.pathFromRoot.map(r => r.url).join('/') }
}
}
if (!this.currentAuthStatus.isAuthenticated || !roleMatch) {
this.showAlert(this.currentAuthStatus.isAuthenticated, roleMatch)
this.router.navigate(['login', params || {}])
return false
}
return true
}
private showAlert(isAuth: boolean, roleMatch: boolean) {
if (!isAuth) {
this.uiService.showToast('You must login to continue')
}
if (!roleMatch) {
this.uiService.showToast('You do not have the permissions to view this resource')
}
}
}
- 使用
CanLoad守卫来阻止惰性加载模块,比如经理的模块:
src/app/app-routing.module.ts
...
{
path: 'manager',
loadChildren: './manager/manager.module#ManagerModule',
canLoad: [AuthGuard],
},
...
在这种情况下,当ManagerModule正在加载时,AuthGuard将在canLoad事件期间被激活,并且checkLogin函数将验证用户的身份验证状态。如果守卫返回false,模块将不会被加载。在这一点上,我们没有元数据来检查用户的角色。
- 使用
CanActivate守卫来阻止个别组件的激活,比如用户的profile:
user/user-routing.module.ts
...
{ path: 'profile', component: ProfileComponent, canActivate: [AuthGuard] },
...
在user-routing.module的情况下,在canActivate事件期间激活AuthGuard,并且checkLogin函数控制这个路由可以导航到哪里。由于用户正在查看自己的个人资料,这里不需要检查用户的角色。
- 使用
CanActivate或CanActivateChild与expectedRole属性来阻止其他用户激活组件,比如ManagerHomeComponent:
mananger/manager-routing.module.ts
...
{
path: 'home',
component: ManagerHomeComponent,
canActivate: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
},
{
path: 'users',
component: UserManagementComponent,
canActivate: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
},
{
path: 'receipts',
component: ReceiptLookupComponent,
canActivate: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
},
...
在ManagerModule内部,我们可以验证用户是否有权访问特定路由。我们可以通过在路由定义中定义一些元数据,比如expectedRole,将其传递给checkLogin函数来实现这一点,该函数将通过canActivate事件来激活。如果用户经过身份验证但其角色与Role.Manager不匹配,AuthGuard将返回 false 并阻止导航。
- 确保
AuthService和AuthGuard都在app.module和manager.module中提供,因为它们在两个上下文中都被使用。
一如既往,在继续之前,请确保通过执行npm test和npm run e2e来通过所有测试。
身份验证服务伪造和常见测试提供者
我们需要实现一个AuthServiceFake,以便我们的单元测试通过,并使用类似于第七章中提到的commonTestingModules模式,方便地在我们的规范文件中提供这个假数据。
为了确保我们的假数据具有与实际AuthService相同的公共函数和属性,让我们首先创建一个接口:
- 将
IAuthService添加到auth.service.ts
src/app/auth/auth.service.ts export interface IAuthService {
authStatus: BehaviorSubject<IAuthStatus>
login(email: string, password: string): Observable<IAuthStatus>
logout()
getToken(): string
}
-
确保
AuthService实现了接口 -
导出
defaultAuthStatus以便重复使用
src/app/auth/auth.service.ts
export const defaultAuthStatus = {
isAuthenticated: false,
userRole: Role.None,
userId: null,
}export class AuthService extends CacheService implements IAuthService
现在我们可以创建一个实现相同接口的假数据,但提供的函数不依赖于任何外部认证系统。
- 在
auth下创建一个名为auth.service.fake.ts的新文件。
src/app/auth/auth.service.fake.ts
import { Injectable } from '@angular/core'
import { BehaviorSubject, Observable, of } from 'rxjs'
import { IAuthService, IAuthStatus, defaultAuthStatus } from './auth.service'
@Injectable()
export class AuthServiceFake implements IAuthService {
authStatus = new BehaviorSubject<IAuthStatus>(defaultAuthStatus)
constructor() {}
login(email: string, password: string): Observable<IAuthStatus> {
return of(defaultAuthStatus)
}
logout() {}
getToken(): string {
return ''
}
}
- 使用
commonTestingProviders更新common.testing.ts:
src/app/common/common.testing.ts
export const commonTestingProviders: any[] = [
{ provide: AuthService, useClass: AuthServiceFake },
UiService,
]
- 观察在
app.component.spec.ts中使用假数据:
src/app/app.component.spec.ts ...
TestBed.configureTestingModule({
imports: commonTestingModules,
providers: commonTestingProviders.concat([
{ provide: ObservableMedia, useClass: ObservableMediaFake },
...
我们之前创建的空commonTestingProviders数组正在与特定于app.component的假数据连接,因此我们的新AuthServiceFake应该自动应用。
- 更新
AuthGuard的规范文件如下所示:
src/app/auth/auth-guard.service.spec.ts ...
TestBed.configureTestingModule({
imports: commonTestingModules,
providers: commonTestingProviders.concat(AuthGuard)
})
-
继续将这种技术应用到所有依赖于
AuthService和UiService的规范文件中 -
值得注意的例外情况是在
auth.service.spec.ts中,您不希望使用假数据,因为AuthService是被测试的类,请确保它配置如下所示:
src/app/auth/auth.service.spec.ts
...
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService, UiService],
})
- 此外,
SimpleDialogComponent的测试需要对一些外部依赖进行存根处理,例如:
src/app/common/simple-dialog/simple-dialog.component.spec.ts
...
providers: [{
provide: MatDialogRef,
useValue: {}
}, {
provide: MAT_DIALOG_DATA,
useValue: {} // Add any data you wish to test if it is passed/used correctly
}],
...
记住,直到所有测试都通过之前不要继续!
摘要
现在您应该熟悉如何创建高质量的身份验证和授权体验了。我们首先讨论了完成和记录整个应用的高级 UX 设计的重要性,以便我们可以正确地设计出色的条件导航体验。我们创建了一个可重用的 UI 服务,以便我们可以方便地将警报注入到我们应用的流程控制逻辑中。
我们介绍了基于令牌的身份验证和 JWT 的基础知识,以便您不会泄漏任何关键用户信息。我们了解到缓存和 HTTP 拦截器是必要的,这样用户就不必在每个请求中输入他们的登录信息。最后,我们介绍了路由守卫,以防止用户意外进入他们未被授权使用的屏幕,并重申了应用程序的真正安全性应该在服务器端实现的观点。
在下一章中,我们将逐一介绍一份全面的 Angular 配方清单,以完成我们的企业应用程序 LemonMart 的实施。