Angular 企业级应用第三版(二)
原文:
zh.annas-archive.org/md5/0bae576facf6820e0cfce21c539985d0译者:飞龙
第四章:创建以路由优先的业务线应用
如你在第三章“架构企业应用”中阅读到的,业务线(LOB)应用是软件开发世界的基石。
在本书的此部分和后续章节中,我们将设置一个具有丰富功能的新应用,以满足具有可扩展架构和工程最佳实践的业务线应用的需求。我们将遵循路由优先的设计模式,依靠可重用组件来创建名为 LemonMart 的杂货店业务线应用。我们将讨论围绕主要数据实体进行设计的重要性,以及在实现各种条件导航元素之前完成应用程序的高级原型设计的重要性,这些元素在设计阶段可能会发生重大变化。
本项目的源代码可在 GitHub 上找到,地址为github.com/duluca/lemon-mart,包括在Projects文件夹中的各个开发阶段。该项目由 Jasmine 单元测试和 Cypress 端到端测试支持,使用环境变量、Angular Material,以及利用 CircleCI 的持续集成和持续交付(CI/CD)管道。你可以在第十章“使用 CI/CD 发布到生产环境”中找到更多关于 CI/CD 的信息。
LemonMart 是一个独立的 Angular 仓库。对于企业或全栈开发,你可能会问,为什么它没有配置为单仓库?在第五章“设计身份验证和授权”中,我们将介绍如何使用 Git 的子模块功能创建单仓库。为了在大型 Angular 应用上工作提供更具有意见和更人性化的方法,我强烈建议你考虑 Nx。它智能的构建系统本身就可以节省数小时的构建时间。请在nx.dev上查看。然而,对这个工具的深入探讨超出了本书的范围。
想要冒险吗?运行以下命令以创建你的 Nx 工作空间:
$ npx create-nx-workspace
在本章中,我们将涵盖以下主题:
-
创建 LemonMart
-
生成具有路由功能的模块
-
品牌化、定制和 Material 图标
-
带有懒加载的功能模块
-
创建行走骨架
-
常见测试模块
-
围绕主要数据实体进行设计
-
高级用户体验设计
在第五章到第九章中,我们将逐步完善 LemonMart 以展示上述概念。
技术要求
书籍的示例代码的最新版本可在以下链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。你可以在本章末尾通过查找projects文件夹下的章节末尾代码快照来验证你的进度。
对于第四章:
-
在根目录下执行
npm install以安装依赖项。 -
项目的最终状态反映在:
projects/stage7 -
将阶段名称添加到任何
ng命令中,使其仅对该阶段生效:npx ng build stage7
注意,存储库根目录下的dist/stage7文件夹将包含编译结果。
请注意,书中提供的源代码和 GitHub 上的版本可能不同。这些项目周围的生态系统是不断演变的。在 Angular CLI 生成新代码的方式、错误修复、库的新版本或多种技术的并行实现之间,存在许多难以计数的差异。如果您发现错误或有疑问,请创建问题,或在 GitHub 上提交拉取请求。
在补充指南保持 Angular 和工具常青中了解更多关于更新 Angular 的信息,该指南位于angularforenterprise.com/evergreen。
接下来,让我们首先创建 LemonMart^™,这是一个功能齐全的 LOB 应用程序,您可以用作启动下一个专业项目的模板。LemonMart 是一个强大且现实的项目,可以支持功能增长和不同的后端实现,并且它自带完整且可配置的认证和授权解决方案。
自 2018 年推出以来,LemonMart 已为超过 32,500 名开发者提供了超过 257,000 个柠檬。真香!
您可以随时从 GitHub 克隆完成的项目,www.github.com/duluca/lemon-mart, whenever needed。让我们直接开始吧。
创建 LemonMart
LemonMart 将是一个中等规模的业务线应用程序,拥有超过 90 个代码文件。我们将从创建一个新的 Angular 应用程序开始,其中已配置路由和 Angular Material。
假设您已安装了附录 A 中提到的所有必需软件,即设置您的开发环境。如果没有,请根据您的操作系统执行以下命令来配置您的环境。
在 Windows PowerShell 中执行:
PS> Install-Script -Name setup-windows-dev-env
PS> setup-windows-dev-env.ps1
在 macOS 终端中执行:
$> bash <(wget -O - https://git.io/JvHi1)
如需更多信息,请参阅github.com/duluca/web-dev-environment-setup。
创建一个路由优先的应用程序
我们将创建 LemonMart 作为一个独立项目,这意味着不需要根模块来启动应用程序,并且应用程序内创建的所有组件都将配置为独立组件。我们将使用懒加载功能模块实现模块化架构,并选择性地使用懒加载的独立组件来共享功能模块中的组件。采用路由优先的方法,我们希望在应用程序早期启用路由:
-
您可以通过执行此命令创建一个新的应用程序,其中已配置路由:
$ npm create @angular (Enter project name) (select SCSS) (respond no to SSR) -
为我们创建了一个新的
app.routes.ts文件:**src/app/app.****routes****.****ts** import { Routes } from '@angular/router' export const routes: Routes = []我们将在路由数组内部定义
routes。 -
注意,
routes在app.config.ts中提供,如下所示:**src/app/app.****config****.****ts** import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [provideRouter(**routes**) ] }; -
最终,
ApplicationConfig在main.ts中被bootstrapApplication消费,从而启动应用程序的引导过程:**src/main.****ts** import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { AppComponent } from './app/app.component'; bootstrapApplication(AppComponent, **appConfig**) .catch((err) => console.error(err)); -
通过运行
npm start来执行您的项目。
配置 Angular 和 VS Code
使用mrm,一个帮助保持项目配置文件同步的命令行工具,应用以下配置步骤到您的项目中:
以下脚本不需要您使用 VS Code。如果您希望使用 WebStorm 等其他 IDE,配置的npm脚本同样可以正常运行。
您可以在mrm.js.org/docs/getting-started了解更多关于mrm的信息。
-
应用 Angular VS Code 配置:
npx mrm angular-vscode -
应用 Docker 配置的
npm脚本:npx mrm npm-docker -
实现一个名为
build:prod的npm脚本来在生产模式下构建您的应用程序:**"scripts"****: {** **...,** **"build:prod"****:** **"ng build --configuration production"****,** **}**默认情况下,Angular 将在生产模式下构建您的代码。但是,这种行为可以在
angular.json中更改。因此,我更喜欢明确请求生产构建,以便将代码发布,以避免错误。这些设置不断调整以适应扩展、插件、Angular 和 VS Code 不断变化的格局。或者,您可以使用 VS Code 的 Angular Evergreen 扩展一键运行配置命令。
注意,如果前面的配置脚本执行失败,以下
npm脚本也将失败。在这种情况下,您有两个选择:撤销您的更改并忽略这些脚本,或者手动实现这些脚本,如前几章所述(或如 GitHub 上所示)。 -
执行
npm run style:fix。 -
执行
npm run lint:fix。 -
执行
npm start。
请参考附录 A,设置您的开发环境,以获取更多配置细节。
关于mrm任务的更多信息,请参阅:
配置 Angular Material 和样式
几年前,将重置或规范化 CSS 样式表应用到主题项目中以解决浏览器处理布局或间距的差异是一个必要的实践。然而,当前浏览器对 CSS 规范的遵循更加严格,因此传统的重置样式表可能过于冗余。以下,我使用具有重置参数如body { margin: 0 }和html, body { height: 100% }的styles.scss实现。
如果您想查看规范化样式表的现代版本,我推荐github.com/sindresorhus/modern-normalize。它易于设置,并在导入到styles.scss时无缝工作。
我们还需要设置 Angular Material 并配置一个要使用的主题:
-
安装 Angular Material:
$ npx ng add @angular/material (select Custom, No to global typography, Yes to browser animations) $ npm i @ngbracket/ngx-layout注意,由于这是一个独立项目,我们将在每个需要它的单个组件中导入所需的 Material 模块和
FlexModule。当@ngbracket/ngx-layout包实现根级提供者时,将不再需要手动添加FlexModule。 -
如以下代码所示,将常见的 CSS 追加到
styles.scss中:**src****/styles****.scss** … html, body { height: 100%; } body { margin: 0; font-family: Roboto, 'Helvetica Neue', sans-serif; } .top-pad { margin-top: 16px; } .h-pad { margin: 0 16px; } .v-pad { margin: 16px 0; } .left-pad { margin-left: 8px; } .flex-spacer { flex: 1 1 auto; } -
在
index.html中更新您应用程序的标题。
我们将在本章的后面部分为应用程序应用自定义品牌。接下来,让我们开始设计我们的业务应用程序。
设计 LemonMart
在数据库到前端的过程中,同时避免过度设计,构建一个基本的路线图来遵循是很重要的。这个初始设计阶段对于项目的长期健康和成功至关重要,其中必须打破团队之间存在的任何隔阂,并且所有团队成员都必须对整体技术愿景有清晰的理解。这比说起来容易做起来难,关于这个主题已经写了很多本书。
在工程领域,对于问题没有绝对正确的答案,因此记住没有人能够拥有所有答案或清晰的愿景是很重要的。在文化中创造一个安全的空间,允许开放讨论和实验,对于技术和非技术领导者来说至关重要。作为一个团队能够共同面对这种不确定性所带来的谦逊和同理心,与任何单个团队成员的技术能力一样重要。每个团队成员都必须感到自在,将他们的自我放下,因为我们的共同目标是在开发周期中不断增长和演变应用程序以适应不断变化的需求。如果你知道你成功了,那么你创建的软件的各个部分将很容易被任何人替换。
因此,让我们先制定一个路线图,并确定我们应用程序的范围。为此,我们将定义用户角色,然后构建一个网站图,以形成一个关于我们的应用程序可能如何工作的愿景。
识别用户角色
我们设计的第一步将是思考谁在使用这个应用程序以及为什么。
我们设想了 LemonMart 的四种用户状态或角色:
-
认证用户:任何认证用户都可以访问他们的个人资料
-
收银员,其唯一职责是结账客户
-
店员,其唯一职责是执行与库存相关的功能
-
经理,可以执行收银员和店员可以执行的所有操作,还可以访问管理功能
考虑到这一点,我们可以开始为我们的应用程序创建一个高级设计。
使用网站图来识别高级模块
按照以下图像所示,开发您应用程序的高级网站图:
图 4.1:用户登录页面
我使用了 MockFlow.com 的 SiteMap 工具来创建显示的网站图:sitemap.mockflow.com。
初步检查后,有三个高级模块被确定为懒加载候选者:
-
销售点(POS)
-
库存
-
经理
收银员只能访问POS模块和组件。店员只能访问库存模块,该模块将包括库存录入、产品和类别管理组件的附加屏幕:
图 4.2:库存页面
最后,经理将能够通过经理模块访问所有三个模块,包括用户管理和收据查找组件:
图 4.3:经理页面
为所有三个模块启用懒加载将带来巨大好处;由于收银员和店员永远不会使用属于其他用户角色的组件,因此没有必要将这些字节发送到他们的设备。随着经理模块获得更多高级报告功能或新角色被添加到应用程序中,POS模块将不会受到其他情况下不断增长的应用程序带宽和内存影响的干扰。
这意味着在相同硬件上支持调用更少,并且性能保持一致的时间更长。
生成具有路由功能的模块
现在我们已经将高级组件定义为经理、库存和POS,我们可以将它们定义为模块。这些模块将不同于您为路由和 Angular Material 创建的模块。我们可以在应用程序模块上创建用户配置文件作为组件;然而,请注意,用户配置文件将仅用于已认证的用户,因此定义一个仅针对一般已认证用户的第四个模块是有意义的。这样,您将确保应用程序的第一个负载尽可能小。此外,我们还将创建一个home组件来包含我们应用程序的着陆体验,这样我们就可以将实现细节排除在app.component之外:
-
通过指定名称和路由能力生成
manager、inventory、pos和user功能模块:$ npx ng g m manager --routing $ npx ng g m inventory --routing $ npx ng g m pos --routing $ npx ng g m user --routing注意简化的命令结构,其中
ng generate module manager变为ng g m manager,同样,--module变为-m。 -
确认您没有 CLI 错误。
注意,在 Windows 上使用
npx可能会抛出错误,例如Path must be a string. Received undefined。这个错误似乎不会影响命令的成功执行,因此始终检查 CLI 工具生成的输出是至关重要的。 -
确认已创建文件夹和文件:
/src/app │ app.component.scss │ app.component.html │ app.component.spec.ts │ app.component.ts │ app.config.ts │ app.routes.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 的配置。记住,功能模块由 @NgModule 注解装饰。在配置有根 NgModule 的 Angular 应用中,你会注意到它实现了 bootstrap 属性,而功能模块没有实现此属性。下面是生成的代码:
**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 {}
由于我们指定了 --routing 选项,已创建并导入到 ManagerModule 中的 routing 模块:
**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 模块或 ApplicationConfig 中的路由提供者。通过指定上下文,我们允许路由器理解不同模块上下文中定义的路由之间的正确关系。例如,在 ManagerRoutingModule 中定义的所有子路由都将由路由段 /manager 预先添加。
在继续之前,务必执行 style 和 lint fix 命令:
$ npm run style:fix && npm run lint:fix
现在,让我们设计 LemonMart 的着陆页将如何看起来和工作。
设计主页路由
将以下模拟作为 LemonMart 的着陆体验考虑:
图 4.4:LemonMart 着陆体验
与 LocalCast 天气应用不同,我们不希望在 AppComponent 中有太多的布局标记。AppComponent 是你整个应用的根元素;因此,它应该只包含将在你的应用中持续出现的元素。在以下注释模拟中,标记为 1 的工具栏将在整个应用中保持不变。
标记为 2 的区域将包含 home 组件,该组件本身将包含一个标记为 3 的登录用户控件:
图 4.5:LemonMart 布局结构
在 Angular 中,将默认或着陆组件作为单独的元素创建是最佳实践。这有助于减少必须加载和在每个页面上逻辑执行的代码量,但它也使得在利用路由器时具有更灵活的架构。
使用内联模板和样式生成 home 组件:
$ npx ng g c home --inline-template --inline-style
注意,具有内联模板和样式的组件也称为 单文件组件 或 SFC。
现在,你已准备好配置路由器。
设置默认路由
让我们开始设置 LemonMart 的简单路由。我们需要设置 / 路由(也称为空路由)和 /home 路由以显示 HomeComponent。我们还需要一个通配符路由来捕获所有未定义的路由并显示一个 PageNotFoundComponent,这也必须被创建:
**src/app/app.****routes****.****ts**
...
**import** **{** **HomeComponent** **}** **from****'./home/home.component'**
**import** **{**
**PageNotFoundComponent**
**}** **from****'./page-not-found/page-not-found.component'**
const routes: Routes = [
**{** **path****:** **''****,** **redirectTo****:** **'home'****,** **pathMatch****:** **'full'** **},**
**{** **path****:** **'home'****,** **component****:** **HomeComponent** **},**
**{** **path****:** **'**'****,** **component****:** **PageNotFoundComponent** **},**
]
...
让我们逐步整理上述路由配置:
-
定义
'home'的路径,并通过设置component属性将路由器指向渲染HomeComponent。 -
将应用程序的默认路径
''设置为重定向到'/home'。通过设置pathMatch属性,我们始终确保这个特定的home路由实例将被渲染为着陆体验;否则,在其默认前缀设置中,pathMatch将考虑空路径为所有路由的前缀,导致无限重定向循环。 -
创建一个具有内联模板的
pageNotFound组件。 -
将
PageNotFoundComponent配置为最后一个条目的通配符路由。
通过将通配符路由配置为最后一个条目,我们处理任何未通过优雅匹配的路由,将其重定向到PageNotFoundComponent。通配符路径必须是数组中的最后一个属性;否则,定义在后面的路由将不会被考虑。
RouterLink
当用户到达PageNotFoundComponent时,我们希望他们能够使用routerLink指令返回到HomeComponent:
在PageNotFoundComponent中,替换内联模板,使用routerLink链接回home:
**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-routes.ts中定义的根路由的根元素,这允许我们在根元素内部定义出口,以动态加载我们希望使用<router-outlet>元素加载的任何内容:
-
将
AppComponent配置为使用内联模板和样式,删除html和scss文件中任何现有的内容。 -
为你的应用程序添加工具栏。
-
将应用程序的名称作为按钮链接添加,以便在点击时将用户带到主页。
-
在组件中导入
RouterLink、RouterOutlet和MatToolbarModule。 -
为内容添加
<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> `,
现在,home的内容将渲染在<router-outlet>内。
品牌、定制和 Material 图标
为了构建一个吸引人且直观的工具栏,我们必须向应用程序引入一些图标和品牌,以便用户可以在熟悉图标的帮助下轻松地导航应用程序。
品牌
在品牌方面,你应该确保你的 Web 应用有一个自定义的色彩调色板,并且与桌面和移动浏览器功能集成,以突出你的应用名称和图标。
色彩调色板
使用位于m2.material.io/design/color/the-color-system.html#tools-for-picking-colors的Material Color工具选择一个色彩调色板。对于 LemonMart,我选择了以下值:
-
主颜色-
#2E7D32:$lemon-mart-primary: mat.define-palette(mat.$green-palette, 800); -
次要颜色-
#C6FF00:$lemon-mart-accent: mat.define-palette(mat.$lime-palette, A400);您可以在
styles.scss中实现您的主题,或者创建一个单独的主题文件。如果打算进一步自定义单个组件,则单独的文件很有用。 -
添加一个名为
lemonmart-theme.scss的文件 -
将与主题相关的 CSS 从
styles.scss移动到新文件。主题相关内容将在以下行之上:**styles****.scss** ... /* You can add global styles to this file and also import other style files */ ... -
将
styles.scss更新为在文件的第一行包含新主题:**styles****.scss** @use 'lemonmart-theme'; ... -
使用所选的色彩调色板配置您的自定义主题。
您还可以从 GitHub 获取与 LemonMart 相关的资源,网址为github.com/duluca/lemon-mart。
对于 LocalCast 天气应用,我们替换了favicon.ico文件,以在浏览器中为我们的应用打上品牌。虽然这在 10 年前就足够了,但今天的设备种类繁多,每个平台都可以更好地利用优化后的资源来代表您的 Web 应用在其操作系统中的表现。接下来,让我们实现一个更健壮的 favicon。
实现浏览器清单和图标
您必须确保浏览器在浏览器标签中显示正确的标题文本和图标。此外,应创建一个实现各种移动操作系统特定图标的清单文件,以便如果用户将您的网站固定,将显示一个类似其他手机应用图标的图标。这将确保如果用户在移动设备的首页上收藏或固定您的 Web 应用,他们将获得一个看起来像原生应用图标的图标:
-
从设计师或类似
www.flaticon.com的网站创建或获取您网站标志的 SVG 版本。 -
在这个例子中,我将使用尤里卡柠檬的相似图像:
图 4.6:LemonMart 的标志性标志
当使用在线找到的图片时,请注意适用的版权。在这种情况下,我已购买许可证以能够发布这个柠檬标志,但您可以在以下 URL 获取自己的副本,前提是您向图片的作者提供所需的归属:
www.flaticon.com/free-icon/lemon_605070。 -
使用工具如
realfavicongenerator.net生成favicon.ico和清单文件。 -
调整 iOS、Android、Windows 和 macOS Safari 的设置以符合您的喜好。
-
在生成器中,务必设置一个版本号,因为 favicon 可能会因缓存而出名;一个随机的版本号将确保用户总是获得最新版本。
-
下载并解压缩生成的
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 声明和 CSS 样式导入之间放置生成的代码。顺序很重要。浏览器自上而下加载数据。您希望应用程序的图标在用户等待下载 CSS 文件之前被解析。
-
确保您的新 favicon 正确显示。
一旦您的基本品牌工作完成,请考虑是否希望通过主题化建立更独特的视觉和感觉。
自定义主题
您可以通过利用以下工具列表中的工具以及我发现的某些其他工具来进一步自定义 Material 的外观和感觉,以实现您应用独特的体验:m2.material.io/resources:
-
Material Design 主题调色板生成器将在
mcg.mbitson.com生成定义您自定义调色板的必要代码,以创建真正独特的主题。 -
颜色混合器有助于找到两种颜色之间的中间点,这在定义颜色样本之间的颜色时很有用,位于
meyerweb.com/eric/tools/color-blend。在 2021 年,Google 宣布了 Material 3,也称为 Material You,这是一个动态主题系统,它适应用户在操作系统级别颜色使用方面的偏好。到 2023 年,Angular Material 仍然基于 Material 2。Angular 团队在 Angular 15 中过渡到新的 Web 端 Material 设计组件(MDC)风格组件,并在 Angular 17 中弃用旧样式。MDC 风格组件支持可调整的密度,因此更加动态。在此里程碑之后,Angular 团队计划解决 Material You 的实现问题。
您可以关注此线程以获取更新:
github.com/angular/components/issues/22738。
在 material.io 上有大量关于 Material 设计深入哲学的信息,包括关于色彩系统等内容,如 material.io/design/color/the-color-system.html,它深入探讨了为您的品牌选择正确的调色板以及其他主题,例如为您的应用创建深色主题。
区分您的品牌与其他应用或竞争对手非常重要。创建高质量的定制主题将是一个耗时的过程;然而,通过给用户留下深刻的第一印象所带来的好处是相当可观的。
接下来,我们将向您展示如何将自定义图标添加到您的 Angular 应用中。
自定义图标
现在,让我们将您自定义的品牌添加到您的 Angular 应用中。您需要用于创建 favicon 的 svg 图标:
-
将图片放置在
src/assets/img/icons目录下,命名为lemon.svg。 -
在
app.config.ts中,添加provideHttpClient()作为提供者,以便可以通过 HTTP 请求.svg文件。 -
更新
AppComponent以注册新的.svg文件作为图标:**src/app/app.****component****.****ts** **import** **{** **MatIconRegistry** **}** **from****'@angular/material/icon'** **import** **{** **DomSanitizer** **}** **from****'@angular/platform-browser'** ... export class AppComponent { **constructor****(** **iconRegistry****:** **MatIconRegistry****,** **sanitizer****:** **DomSanitizer** **) {** **iconRegistry.****addSvgIcon****(** **'lemon'****,** **sanitizer.****bypassSecurityTrustResourceUrl****(** **'assets/img/icons/lemon.svg'** **)** **)** } }注意,从 URL 资源添加
svg图标在 服务器端渲染(SSR)配置中不起作用。相反,您可以将svg图标作为 TypeScript 文件导入中的const字符串添加,并按以下方式注册:import { LEMON_ICON } from './svg.icons'iconRegistry. addSvgIconLiteral('lemon', sanitizer. bypassSecurityTrustHtml(LEMON_ICON)) -
导入
MatIconModule。 -
按照在
material.angular.io/components/toolbar文档中找到的MatToolbar模式,将图标添加到工具栏:**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> `,
现在,让我们添加菜单、用户个人资料和注销的剩余图标。
材料图标
Angular Material 与 Material Design 图标字体无缝配合,自动作为网页字体导入到您的应用中,位于index.html。您可以自行托管字体;然而,如果您选择这条路,如果用户的浏览器已经从访问另一个网站时缓存了字体,那么您将无法获得好处,这可能会在下载 42-56 KB 文件的过程中节省速度和延迟。完整的图标列表可以在fonts.google.com/icons找到。
现在让我们更新工具栏并添加一些图标,设置主页并使用最小模板添加一个假登录按钮:
-
确保材料图标的
<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的自托管部分可以找到自托管说明。一旦配置完成,使用材料图标就变得简单。
-
在
AppComponent上,更新工具栏,将菜单按钮放置在标题的左侧。 -
添加
fxFlex指令,以便剩余的图标右对齐。 -
导入
FlexModule和MatButtonModule。 -
添加用户个人资料和注销图标:
**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> `, -
在
HomeComponent上,添加一个用于登录体验的最小模板,替换任何现有内容。别忘了导入FlexModule和MatButtonModule:**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, Limoncu!</span> <button mat-raised-button color="primary">Login</button> </div> `
您的应用应该看起来与这张截图相似:
图 4.7:LemonMart 的最小登录界面
由于用户的认证状态,在实现和显示/隐藏菜单、个人资料和注销图标方面还有一些工作要做。我们将在第七章,使用 REST 和 GraphQL API中介绍这个功能。
要调试路由,获取您的路由的可视化,并将 Angular 紧密集成到 Chrome 的调试功能中,请使用从 Chrome Web Store(也兼容 Microsoft Edge)或 Firefox 插件angular.dev/tools/devtools提供的 Angular DevTools。
现在您已经为您的应用设置了基本的路由,我们可以继续设置带有子组件的懒加载模块。如果您不熟悉 Angular 的故障排除和调试,请在继续之前查阅angular.dev/tools/devtools。
带有懒加载的功能模块
资源加载有两种方式:急切加载或懒加载。当浏览器加载你的应用的index.html时,它从上到下开始处理。首先处理<head>元素,然后是<body>。例如,我们在应用的<head>中定义的 CSS 资源将在我们的 Angular 应用在 HTML 文件的<body>中定义为<script>之前下载,因为我们的 Angular 应用被定义为 HTML 文件的<body>中的<script>。
当你使用ng build命令时,Angular 利用 webpack 模块打包器将所有 JavaScript、HTML 和 CSS 组合成最小化和优化的 JavaScript 包。
如果你不在 Angular 中使用懒加载,你的应用的所有内容都将被急切加载。用户将看不到你的应用的第一屏,直到所有屏幕都下载并加载完成。
懒加载允许 Angular 构建过程与 webpack 协同工作,将你的 Web 应用分割成不同的 JavaScript 文件,称为 chunks。我们可以通过将应用程序的部分分离到功能模块中来实现这种 chunking。功能模块及其依赖项可以捆绑到单独的 chunks 中。请记住,根模块及其依赖项将始终在第一个下载的 chunk 中。因此,通过 chunking 我们的应用程序的 JavaScript 包大小,我们保持初始 chunk 的大小最小。有了最小化的第一个 chunk,无论你的应用如何增长,首次有意义的绘制时间保持不变。否则,随着你向应用添加更多功能和功能,你的应用将需要更长的时间来下载和渲染。懒加载对于实现可扩展的应用程序架构至关重要。
考虑以下图形以确定哪些路由是急切加载的,哪些是懒加载的:
图 4.8:Angular 路由急切加载与懒加载
黑色三角形是独立组件,而黑色圆圈是依赖于模块的组件。rootRouter定义了三条路由:a、b和c。/master和/detail代表命名路由出口,这在第九章、食谱 – 主/详情、数据表和 NgRx中有详细说明。路由a是应用的默认路由。路由a和c用实线连接到rootRouter,而路由b则使用虚线连接。在这种情况下,路由b被配置为懒加载路由。这意味着路由b将动态加载一个包含childRouter的功能模块BModule。childRouter可以定义任意数量的组件,甚至可以重用其他地方已经重用的路由名称。在这种情况下,b定义了两个额外的路由:/b/a和/b/b。
考虑rootRouter的示例路由定义:
**rootRouter example**
const routes: Routes = [
{ path: '', redirectTo: '/a', pathMatch: 'full' },
{
path: 'a',
component: AComponent,
children: [
{ path: '', component: MasterComponent, outlet: 'master' },
{ path: '', component: DetailComponent, outlet: 'detail' },
],
},
{
path: 'b',
loadChildren:
() => import('./b/b.module')
.then((module) => module.BModule),
canLoad: [AuthGuard],
},
{ path: 'c', loadChildren: () => import('./c/routes').then(mod => mod.C_ROUTES)},},
{ path: '**', component: PageNotFoundComponent },
]
注意,路由/b/a、/b/b、/c/a和/c/b的定义在rootRouter中不存在。请参阅childRouter的示例路由定义:
**/b childRouter example**
const routes: Routes = [
{ path: '', redirectTo: '/b/a', pathMatch: 'full' },
{ path: 'a', component: BAComponent },
{ path: 'b', component: BBComponent },
]
**/c route config example**
const routes: Routes = [
{ path: '', redirectTo: '/c/a', pathMatch: 'full' },
{ path: 'a', component: CAComponent },
{ path: 'b', component: CBComponent },
]
如您所见,childRouter 中定义的路由与 rootRouter 中定义的路由是独立的。子路由存在于一个层次结构中,其中 /b 是父路径。要导航到 BAComponent,您必须使用路径 /b/a,要导航到 CAComponent,则使用 /c/a。
给定此示例配置,rootRouter 中定义的每个组件及其依赖项都将包含在我们应用的第一个块中,因此会预先加载。第一个块将包括组件 A、Master、Detail 和 PageNotFound。第二个块将包含组件 BA 和 BB。这个第二个块将在用户导航到以 /b 开头的路径之前不会下载或加载;因此,它是懒加载的。在独立配置中,这种分块可以在组件级别上更细致。
在我们添加跨不同模块使用的共享组件时,我在 第八章、食谱 – 可重用性、表单和缓存 中介绍了如何处理懒加载独立组件。
您可以在 angular.io/guide/standalone-components#lazy-loading-a-standalone-component 上了解更多详细信息。
我们现在将介绍如何设置具有组件和路由的功能模块。我们还将使用 Angular DevTools 来观察我们各种路由配置的效果。
使用组件和路由配置功能模块
管理模块需要一个着陆页,如图所示:
图 4.9:管理员的仪表板
让我们从创建 ManagerModule 的主屏幕开始:
-
创建
ManagerHome组件:$ npx ng g c manager/managerHome manager -s -t要在
manager文件夹下创建新组件,我们必须在组件名称前加上manager/前缀。由于这是一个另一个着陆页,它不太可能复杂到需要单独的 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.ts中配置ManagerHome组件的路由,类似于我们在app.route.ts中配置Home组件的方式:**src/app/manager/manager-routing.****module****.****ts** import { ManagerHomeComponent } from './manager-home/manager-home.component' const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'home', component: ManagerHomeComponent }, ]
注意,http://localhost:4200/manager 还没有解析到组件,因为我们的 Angular 应用不知道 ManagerModule 的存在。在独立项目中预先加载模块根本就没有意义;我们只会考虑功能模块的懒加载。
接下来,让我们实现 ManagerModule 的懒加载,以便 Angular 可以导航到它。
懒加载
懒加载代码可能看起来像是黑魔法(即误解)代码。为了从不同的模块加载路由,我们知道我们不能简单地导入它们;否则,它们将被预先加载。答案在于使用 loadChildren 属性配置路由,并使用内联 import 语句通知路由器如何在 app.routes.ts 中加载功能模块:
-
在
app.routes.ts中,使用loadChildren属性实现或更新'manager'路径:**src/app/app.****routes****.****ts** import { Routes } from '@angular/router' import { HomeComponent } from './home/home.component' import { PageNotFoundComponent } from './page-not-found/page-not-found.component' const routes: Routes = [ ... { path: 'manager', loadChildren: () => import('./manager/manager.module') . then(m=> m.ManagerModule), }, ... ] ...懒加载是通过一种巧妙的技巧实现的,避免了在文件级别使用
import语句。将一个函数委托设置到loadChildren属性,该属性包含一个内联import语句,定义了功能模块文件的位置,例如./manager/manager.module,允许我们以类型安全的方式引用ManagerModule而无需完全加载它。内联import语句可以在构建过程中被解释,以创建一个单独的 JavaScript 块,只有在需要时才能下载。ManagerModule作为功能模块是自给自足的;它管理所有子依赖项和路由。 -
考虑到
manager现在是它们的根路由,更新manager-routing.module.ts路由:**src/app/manager/manager-routing.module.ts** const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'home', component: ManagerHomeComponent }, ]我们现在可以将
ManagerHomeComponent的路由更新为更有意义的'home'路径。这个路径不会与app.routes.ts中的路径冲突,因为在当前上下文中,'home'解析为'manager/home',同样地,当path为空时,URL 将看起来像http://localhost:4200/manager。 -
重新启动
ng serve或npm start命令,以便 Angular 可以正确地分块应用程序。 -
导航到
http://localhost:4200/manager。 -
通过观察 CLI 输出是否包含一个新的 Lazy Chunk Files 部分,以确认懒加载是否正常工作:
Lazy Chunk Files | Names | Raw Size | src_app_manager_module_ts.js| manager-module | 358.75 kB |
我们已成功设置了一个具有懒加载的功能模块。接下来,让我们为 LemonMart 实现行走骨架。
创建行走骨架
使用本章早期为 LemonMart 创建的网站图,我们需要为应用程序创建行走骨架导航体验。为了创建这种体验,我们必须创建一些按钮来链接所有模块和组件。我们将按模块逐一进行。
在我们开始之前,更新 HomeComponent 上的 Login 按钮以使用 routerLink 属性导航到 'manager' 路径,并重命名该按钮:
**src/app/home/home.****component****.****ts**
...
<button mat-raised-button color="primary" routerLink="/manager">
Login as Manager
</button>
...
现在,我们可以通过点击 Login 按钮导航到 ManagerHome 组件。
管理模块
由于我们已为 ManagerModule 启用了懒加载,让我们继续完成其余的导航元素。
在当前设置中,ManagerHomeComponent 在 AppComponent 模板中定义的 <router-outlet> 中渲染,因此当用户从 HomeComponent 导航到 ManagerHomeComponent 时,AppComponent 中实现的工具栏将保持原位。参见以下 管理仪表板 的模拟图:
图 4.10:应用范围和功能模块工具栏
应用范围工具栏无论我们导航到哪里都保持不变。想象一下,我们可以为ManagerModule中持续存在的功能模块实现一个类似的工具栏。因此,导航的用户管理和收据查找按钮将始终可见。这允许我们在模块之间创建一致的 UX 来导航子页面。
要实现一个次要工具栏,我们需要复制AppComponent和HomeComponent之间的父子关系,其中父元素实现工具栏和<router-outlet>,以便子元素可以渲染在那里:
-
首先创建基本的
manager组件:$ npx ng g c manager/manager --flat -s -t--flat选项跳过目录创建,并将组件直接放置在manager文件夹下,就像位于app文件夹下的AppComponent一样。 -
在
ManagerComponent中实现一个带有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" fxLayoutGap="8px"> <a mat-button routerLink="home" routerLinkActive="active-link"> Manager's Dashboard </a> <a mat-button routerLink="users" routerLinkActive="active-link"> User Management </a> <a mat-button routerLink="receipts" routerLinkActive="active- link"> Receipt Lookup </a> </mat-toolbar> <router-outlet></router-outlet> `,在独立项目中,每个新组件都是作为一个独立组件创建的。这意味着每个组件都必须导入它自己的依赖项。别忘了在模板中逐个导入每个使用的功能。
-
创建子页面的组件:
$ npx ng g c manager/userManagement $ npx ng g c manager/receiptLookup -
创建父子路由。我们知道我们需要以下路由才能导航到我们的子页面,如下所示:
**example** { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'home', component: ManagerHomeComponent }, { path: 'users', component: UserManagementComponent }, { path: 'receipts', component: ReceiptLookupComponent },
要针对在ManagerComponent中定义的<router-outlet>,我们需要首先创建一个父路由,然后指定子页面的路由:
**src/app/manager/manager-routing.****module****.****ts**
...
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import {
ManagerHomeComponent
} from './manager-home/manager-home.component'
import {
ManagerComponent
} from './manager.component'
import {
ReceiptLookupComponent
} from './receipt-lookup/receipt-lookup.component'
import {
UserManagementComponent
} from './user-management/user-management.component'
const routes: Routes = [
{
path: '',
component: ManagerComponent,
children: [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: ManagerHomeComponent },
{ path: 'users', component: UserManagementComponent },
{ path: 'receipts', component: ReceiptLookupComponent },
],
},
]
现在,你应该能够导航到应用中。当你点击登录为管理员按钮时,你将被带到这里显示的页面。可点击的目标被突出显示:
图 4.11:带有所有路由链接高亮显示的管理员仪表板
如果你点击LemonMart,你将被带到主页。如果你点击管理员的仪表板、用户管理或收据查找,你将被导航到相应的子页面,而活动链接将在工具栏上加粗并下划线。
用户模块
用户登录后,可以通过侧边导航菜单访问他们的个人资料并查看在 LemonMart 应用中可以访问的操作列表。在第六章,实现基于角色的导航中,当我们实现身份验证和授权时,我们将从服务器接收用户的角色。根据用户的角色,我们可以自动导航或限制用户可以看到的选项。我们将在这个模块中实现这些组件,以便它们只在用户登录时加载。为了完成行走骨架,我们将忽略与身份验证相关的关注点:
-
创建必要的组件:
$ npx ng g c user/profile $ npx ng g c user/logout -t -s $ npx ng g c user/navigationMenu -t -s -
实现路由。
从在
app.routes.ts中实现懒加载开始:**src/app/app.****routes****.****ts** ... { path: 'user', loadChildren: () => import('./user/user.module') .then(m => m.UserModule), },如前所述,确保
PageNotFoundComponent路由始终是app.routes.ts中的最后一个路由——因为它有一个通配符匹配器,它将覆盖其后的路由定义。现在在
user-routing.module.ts中实现子路由:**src/app/user/user-routing.****module****.****ts** ... const routes: Routes = [ { path: 'profile', component: ProfileComponent }, { path: 'logout', component: LogoutComponent }, ]我们正在为
NavigationMenuComponent实现路由,因为它将被直接用作 HTML 元素。此外,由于UserModule没有登录页面,因此没有定义默认路径。 -
在
AppComponent中连接user和logout图标:**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指令,并确保按需导入MatTooltipModule。此外,确保为仅图标按钮添加aria-label,以便依赖屏幕阅读器的残障用户仍然可以导航您的 Web 应用程序。 -
确保应用程序正常工作。
您会注意到两个按钮彼此之间太近,如下所示:
图 4.12:带有图标的工具栏
-
您可以通过在
<mat-toolbar>中添加fxLayoutGap="8px"来解决图标布局问题;然而,现在柠檬标志与应用程序名称的距离太远,如下所示:图 4.13:带有填充图标的工具栏
-
通过合并图标和按钮可以修复标志布局问题:
**src/app/app.****component****.****ts** ... <mat-toolbar> ... <a mat-icon-button routerLink="/home"> <mat-icon svgIcon="lemon"></mat-icon> LemonMart </a> ... </mat-toolbar>如以下截图所示,分组解决了布局问题:
图 4.14:带有分组和填充元素的工具栏
-
另一个替代方案是将文本包裹在
<span>标签中;然而,在这种情况下,您需要添加一些填充以保持外观:<span class="left-pad" data-testid="title">LemonMart</span>
从用户体验的角度来看,这更令人满意;现在,用户可以通过点击柠檬返回主页。
POS 和库存模块
我们的行走骨架扮演管理者的角色。为了能够访问我们即将创建的所有组件,我们需要使管理者能够访问 POS 和库存模块。
用两个新按钮更新ManagerComponent:
**src/app/manager/manager.****component****.****ts**
<mat-toolbar color="accent" **fxLayoutGap=****"8px"**>
...
**<span** **class****=****"flex-spacer"****><****/span>**
**<button**
**mat-mini-fab routerLink="/i****nventory****"**
**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的领域,因此管理特定的二级工具栏消失是正常的。
现在,将取决于您来实现最后两个剩余的模块。对于这两个新模块,我提供了高级步骤,并指导您参考先前的模块,您可以在其中为新模块建模。如果您遇到困难,请参考 GitHub 项目github.com/duluca/lemon-mart中的projects/stage7文件夹。
PosModule
PosModule与UserModule非常相似,除了PosModule是默认路径。PosComponent将是默认组件。这可能是一个具有一些子组件的复杂组件,因此不要使用内联模板或样式:
-
创建
PosComponent。 -
将
PosComponent注册为默认路径。 -
为
PosModule配置懒加载。 -
确保应用程序正常工作。
现在让我们实现InventoryModule。
InventoryModule
InventoryModule与ManagerModule非常相似,如下所示:
图 4.15:库存仪表板原型
-
创建一个基本的
Inventory组件。 -
注册
MaterialModule。 -
创建Inventory Home、Stock Entry、Products和Categories组件。
-
在
inventory-routing.module.ts中配置父子路由。 -
为
InventoryModule配置懒加载。 -
在
InventoryComponent中实现一个用于内部InventoryModule导航的二级工具栏。 -
确保应用程序按如下所示工作:
图 4.16:LemonMart 库存仪表板
现在应用程序的行走骨架已经完成,检查 CLI 输出以确保所有预期的模块或组件都被懒加载是很重要的。
在继续之前,确保解决任何测试错误。确保npm test和npm run e2e执行时没有错误。
通用测试模块
现在我们有很多模块要处理,为每个spec文件单独配置导入和提供者变得繁琐。为此,创建一个通用测试模块来包含一个通用的配置,你可以在整个项目中重用它。
首先,创建一个新的.ts文件:
-
创建
common/common.testing.ts。 -
用常见的测试提供者、模拟和模块填充它。
我提供了一个commonTestingModules数组:
**src/app/common/common.****testing****.****ts**
import {
HttpClientTestingModule
} from '@angular/common/http/testing'
import { ReactiveFormsModule } from '@angular/forms'
import {
NoopAnimationsModule
} from '@angular/platform-browser/animations'
import { RouterTestingModule } from '@angular/router/testing'
import {
MatIconTestingModule
} from '@angular/material/icon/testing'
export const commonTestingProviders = [
// Intentionally left blank! Used in later chapters.
]
export const commonTestingModules = [
ReactiveFormsModule,
NoopAnimationsModule,
HttpClientTestingModule,
RouterTestingModule,
MatIconTestingModule,
] as unknown[]
现在让我们看看这个共享配置文件的示例用法:
**src/app/app.****component****.****spec****.****ts**
...
describe('AppComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [...commonTestingModules, AppComponent],
providers: [],
}).compileComponents()
}))
...
虽然commonTestingModules很方便,但随着你的应用程序增长,它将通过导入不必要的模块开始减慢测试运行。独立组件在很大程度上有助于缓解这个问题,因为它们会带来自己的导入。注意不要过度使用这个便利模块。
停!你确保了所有单元测试都通过了吗?为了确保你的测试总是通过,在 CircleCI 中实现一个 CI 管道,如第十章,使用 CI/CD 发布到生产中所示。
当你的测试运行起来后,LemonMart 的行走骨架就完成了。现在,让我们展望未来,开始思考我们可能会处理哪些类型的数据实体。
围绕主要数据实体进行设计
路由优先架构的第四步是实现无状态、数据驱动的架构。为了实现这一点,围绕主要数据组件组织你的 API 非常有帮助。这大致符合你在 Angular 应用程序中消费数据的方式。我们将从定义我们的主要数据组件开始,创建一个粗略的数据实体关系图(ERD)。在第五章,设计身份验证和授权中,我们将回顾使用 Swagger.io 和 Express.js 进行 REST 以及 Apollo 进行 GraphQL 的用户数据实体的 API 设计和实现。
定义实体
让我们先看看你希望存储哪些类型的实体以及这些实体之间可能如何相互关联。
这里是 LemonMart 的一个示例设计,使用draw.io创建:
图 4.17:LemonMart 的 ERD
目前,无论您的实体存储在 SQL 还是 NoSQL 数据库中,这并不重要。我的建议是坚持你所知道的,但如果你是从头开始的,NoSQL 数据库如 MongoDB 将提供最大的灵活性,因为你的实现和要求不断发展。
通常,您需要为每个实体提供 CRUD API。考虑到这些数据元素,我们还可以想象围绕这些 CRUD API 的用户界面。让我们接下来做这件事。
高级用户体验设计
模拟图对于确定整个应用中需要哪些组件和用户控件非常重要。任何将在组件间使用的用户控件或组件必须在根级别定义,其他控件必须在其自己的模块中定义。
在本章早期,我们确定了子模块并为他们设计了着陆页以完成行走骨架。现在我们已经定义了主要的数据组件,我们可以完成应用其余部分的模拟。在设计高级别屏幕时,请记住以下几点:
-
用户能否以尽可能少的导航完成他们角色所需的一般任务?
-
用户能否通过屏幕上的可见元素轻松访问应用的所有信息和功能?
-
用户能否轻松搜索他们所需的数据?
-
一旦用户找到感兴趣的记录,他们能否轻松地深入到详细记录或查看相关记录?
-
那个弹出警告是否必要?你知道用户不会阅读它,对吧?
记住,没有一种正确的方式来设计任何用户体验,这就是为什么在设计屏幕时,你应该始终考虑模块化和可重用性。
创建一个工件 wiki
如本章前面所述,记录你创建的每个工件非常重要。Wiki 提供了一种创建可协作更新或编辑的活文档的方式。虽然 Slack、Teams、电子邮件和白板提供了良好的协作机会,但它们的短暂性质仍有待改进。
因此,当你生成各种设计工件,如模拟图或设计决策时,请确保将它们发布在所有团队成员都能访问的 wiki 上:
- 在 GitHub 上,切换到Wiki标签。
您可以查看我的示例 wiki,网址为github.com/duluca/lemon-mart/wiki,如下所示:
图 4.18:GitHub.com LemonMart wiki
-
在创建 wiki 页面时,确保您与其他任何可用的文档交叉链接,例如Readme。
-
注意,GitHub 在Pages下显示 wiki 的子页面。
-
然而,一个额外的总结是有帮助的,例如设计工件部分,因为有些人可能会错过右侧的导航元素。
-
随着你完成原型,将它们发布在维基百科上。
您可以在这里看到维基百科的摘要视图:
图 4.19:LemonMart 原型的摘要视图
现在您的工件已集中在一个地方,所有团队成员都可以访问。他们可以添加、编辑、更新或整理内容。这样,您的维基百科就变成了您团队所需信息的实用、活生生的文档,而不是您感觉被迫创建的文档。如果你曾经发现自己处于那种情况,请举手!
接下来,将您的原型集成到您的应用中,以便您可以收集利益相关者的早期反馈并测试您应用程序的流程。
在您的应用中利用原型
将原型放置在可步行骨骼应用中,以便测试人员可以更好地设想尚未开发的功能。在这里查看这个想法的示例:
图 4.20:在 UI 中使用原型来验证应用流程
这在设计和实现您的身份验证和授权工作流程时也会很有帮助。随着原型的完成,我们需要在第五章,设计身份验证和授权中实现后端,然后我们才能继续在第六章,实现基于角色的导航中实现 LemonMart 的身份验证和授权工作流程。
摘要
在本章中,您学会了如何有效地使用 Angular CLI 创建主要的 Angular 组件和脚手架。您创建了您应用的标志,利用自定义和内置的 Material 图标。
您学会了如何使用 Angular DevTools 调试复杂的路由配置。最后,您开始构建以路由器为优先的应用程序,早期定义用户角色,考虑到懒加载进行设计,并在早期确定可步行骨骼导航体验。我们讨论了围绕主要数据实体进行设计。我们还介绍了完成并记录整个应用的高级 UX 设计的重要性,以便我们可以正确设计出色的条件导航体验。
回顾一下,要实现以路由器为优先的实现方式,你需要做以下这些:
-
制定路线图和范围。
-
考虑到懒加载进行设计。
-
实现一个可步行骨骼导航体验。
-
实现无状态、数据驱动的架构。
-
强制实施解耦的组件架构。
-
区分用户控件和组件。
-
使用 TypeScript 和 ES6 最大化代码重用。
在本章中,你执行了步骤 1-3;在接下来的章节中,你将执行步骤 4-7。在第五章,设计身份验证和授权中,你将看到使用最小 MEAN 栈的完整栈实现。在第六章,基于角色的导航实现和第七章,与 REST 和 GraphQL API 一起工作中,我们将深入探讨面向对象设计、继承和抽象,以及深入考虑安全性和设计条件导航体验。第八章,食谱 – 可重用性、表单和缓存和第九章,食谱 – 主/详细信息、数据表和 NgRx将通过坚持解耦组件架构,明智地选择创建用户控件和组件,以及通过使用各种 TypeScript、RxJS 和 Angular 编码技术最大化代码重用,将所有内容结合起来。
练习
到目前为止,我们还没有实现懒加载组件。作为一个挑战,按照angular.io/guide/standalone-components中的文档更新app.route.ts,以便PageNotFoundComponent可以懒加载。更新完成后,验证 CLI 输出是否正确显示了新的分块文件,并且打开 DevTools 的网络选项卡,以监视在导航应用程序时下载的分块。
进一步阅读
-
DevTools 概述:
angular.io/guide/devtools -
材料设计:
m3.material.io -
使用独立组件入门,谷歌,2023 年 8 月 30 日:
angular.io/guide/standalone-components -
Webpack 模块打包器:
webpack.js.org/
问题
尽可能地回答以下问题,以确保你已理解本章的关键概念,而无需进行任何谷歌搜索。你知道你是否答对了所有问题吗?访问angularforenterprise.com/self-assessment获取更多信息:
-
根模块和功能模块之间有什么区别?
-
懒加载有什么好处?
-
独立组件与模块有何不同?
-
为什么我们要创建应用程序的行走骨架?
-
围绕主要数据实体进行设计有什么好处?
-
为什么我们应该为我们的项目创建维基?
第五章:设计认证和授权
设计一个高质量且不会让最终用户感到沮丧的 认证 和 授权 系统是一个难以解决的问题。认证是验证用户身份的行为,授权指定用户必须拥有的访问资源的权限。这两个过程,简称 auth,必须无缝协同工作,以满足具有不同角色、需求和职能的用户的需求。
在今天的网络中,用户对任何通过浏览器遇到的认证系统都有很高的基线期望,因此这是您应用程序中一个重要的部分,需要第一次就做对。用户应该始终知道他们可以在您的应用程序中做什么以及不能做什么。如果有错误、失败或错误,用户应该被告知它们发生的原因。随着您的应用程序的增长,很容易错过错误条件可能被触发的机制。您的实现应该易于扩展或维护。否则,您应用程序的基本骨架将需要大量的维护。在本章中,我们将探讨创建出色的认证用户体验和实现坚实基础体验的挑战。
在本章中,我们将围绕上一章定义的用户实体实现基于令牌的认证方案。为了实现健壮且易于维护的实现,我们将深入探讨面向对象编程(OOP),包括抽象、继承和工厂,同时实现一个缓存服务、一个 UI 服务和一个内存中的模拟认证服务,用于测试和教育目的。
在本章中,我们将介绍以下主题:
-
设计认证工作流程
-
TypeScript 的安全数据处理运算符
-
实现数据实体
-
利用面向对象编程(OOP)概念的可重用服务
-
创建认证服务
-
使用 localStorage 的缓存服务
-
一个内存中的认证服务
-
登出
-
一个 HTTP 拦截器
技术要求
书籍的示例代码的最新版本可以在以下链接的 GitHub 仓库中找到。github.com/duluca/lemon-mart。该仓库包含代码的最终和完成状态。您可以在本章末尾通过查找 projects 文件夹下的章节末尾代码快照来验证您的进度。
对于 第五章:
-
克隆
github.com/duluca/lemon-mart仓库。 -
在根目录下执行
npm install以安装依赖项。 -
项目的初始状态反映在:
projects/stage7 -
项目的最终状态反映在:
projects/stage8 -
将阶段名称添加到任何
ng命令中,使其仅对该阶段生效:npx ng build stage8注意,存储库根目录下的
dist/stage8文件夹将包含编译结果。
请注意,书中提供的源代码和 GitHub 上的版本可能不同。围绕这些项目的生态系统一直在不断演变。由于 Angular CLI 生成新代码的方式的变化、错误修复、库的新版本以及多种技术的并行实现,存在许多难以预料的差异。如果您发现错误或有疑问,请创建一个 GitHub 上的问题或提交一个 pull request。
让我们先了解一下基于令牌的认证工作流程是如何工作的。
设计认证工作流程
一个精心设计的认证工作流程是无状态的,因此没有会话过期的概念。用户可以从他们想要的任何设备和标签页中与您的无状态 REST API 交互,同时或分时进行。JSON Web Token(JWT)实现了基于声明的分布式认证,可以使用消息认证码(MAC)进行数字签名或信息保护以及/或加密。这意味着一旦用户的身份得到验证(即在登录表单上的密码挑战),他们就会收到一个编码的声明的票据或令牌,然后可以使用它来向系统发出未来的请求,而无需重新验证用户的身份。
服务器可以独立验证这个声明的有效性,并处理请求,而无需事先知道是否与该用户交互过。因此,我们不需要存储有关用户的会话信息,这使得我们的解决方案是无状态的,易于扩展。每个令牌在预定义的期限后会过期,由于它们的分布式特性,它们不能远程或单独撤销;然而,我们可以通过插入自定义账户和用户角色状态检查来增强实时安全性,以确保认证用户有权访问服务器端资源。
JWT 实现了位于tools.ietf.org/html/rfc7519的互联网工程任务组(IETF)行业标准 RFC 7519。
一个好的授权工作流程允许根据用户的角色进行条件导航,这样用户就会被自动带到最佳的着陆页面;不适合他们角色的路由和 UI 元素不应显示,如果他们不小心尝试访问受限路径,应阻止他们这样做。您必须记住,任何客户端基于角色的导航仅仅是一种便利,并不用于安全。
这意味着每个发送到服务器的调用都应该包含必要的头信息,包括安全的令牌,以便服务器可以重新认证用户并独立验证其角色。只有在这种情况下,他们才能被允许检索受保护的数据。由于客户端认证的本质,它不能被信任。所有认证逻辑都必须在服务器端实现。安全地实现密码重置屏幕可能特别具有挑战性,因为它们可以在您的 Web 应用程序内部触发或通过嵌入到电子邮件/通知中的链接触发。当交互模式增加时,攻击面也随之增长。因此,我建议使用服务器端渲染来构建重置屏幕,以便用户和服务器都可以验证预期的用户正在与系统交互。如果您在客户端实现此功能,您必须确保服务器生成一个时间有限的、一次性的令牌,以便与新的密码一起传递,这样您可以合理地确信请求是合法的。接下来,让我们深入了解如何生成安全的令牌。
JWT 生命周期
JWTs 补充了无状态 REST API 架构,通过加密令牌机制,使得客户端请求的认证和授权变得方便、分布式且高性能。基于令牌的认证方案有三个主要组成部分:
-
客户端:捕获登录信息并隐藏不允许的操作,以提供良好的用户体验。
-
服务器端:验证每个请求是否已认证并且具有适当的授权。
-
认证服务:生成和验证加密令牌,并独立验证用户请求的认证状态,这些请求来自数据存储。
一个安全的系统假定客户端(应用程序和浏览器)、系统(服务器和服务)以及数据库之间发送/接收的数据都使用 传输层安全性(TLS)进行加密,这本质上是一个 安全套接字层(SSL)的新版本。您的 REST API 必须使用正确配置的 SSL 证书托管,通过 HTTPS 提供所有 API 调用,以确保用户凭证在客户端和服务器之间不会被暴露。同样,任何数据库或第三方服务调用也应通过 TLS 进行。这确保了传输中数据的安全性。
在静止状态(数据存储在数据库中时),应使用安全的单向哈希算法和良好的盐值实践来存储密码。
所有的哈希和盐值讨论让你想起了早餐吗?不幸的是,它们是密码学相关的术语。如果你想了解更多,可以查看这篇文章:crackstation.net/hashing-security.htm。
对于敏感用户信息,如 个人身份信息(PII),应使用安全的双向加密算法在静止状态下加密,与密码不同。密码是经过散列的,因此我们验证用户提供的密码是否与系统所知的密码相同。对于 PII,我们必须能够解密数据以将其显示给用户。然而,由于数据在静止状态下加密,如果数据库被破坏,那么被黑客窃取的数据将毫无价值。
采取分层的安全方法至关重要,因为攻击者需要完成同时破坏你安全所有层的不太可能的事情,以对你的业务造成实质性伤害。
有趣的事实:当你听到来自大型公司的重大数据泄露事件时,其根本原因往往是缺乏对传输中或静止状态安全性的适当实施。有时,这是因为持续加密/解密数据计算成本过高,因此工程师依赖于防火墙的保护。在这种情况下,一旦外围被突破,正如他们所说,狐狸就进入了鸡舍。
考虑以下序列图,它突出了基于 JWT 的认证生命周期:
图 5.1:基于 JWT 的认证生命周期
初始时,用户通过提供用户名和密码进行登录。一旦验证通过,用户的认证状态和角色将被加密在一个带有过期日期和时间的 JWT 中,并将其发送回浏览器。
我们的应用程序(Angular 或其他)可以安全地将此令牌缓存到本地或会话存储中,这样用户就不必在每次请求时都强制登录。这样,我们就不必采取像在 cookies 中存储用户凭据这样的不安全做法,以提供良好的用户体验。
我们的技术审稿人 Jurgen Van de Moere 指出,cookies 并不一定是不可靠的。
请参阅 www.youtube.com/watch?v=9ZOpUtQ_4Uk 由 Philippe De Ryck 撰写的视频,解释在特定情况下 cookies 可以是一个有效的机制来存储 JWT 令牌。
当你在本章后面实现自己的认证服务时,你会更好地理解 JWT 生命周期。在接下来的几节中,我们将围绕 用户 数据实体设计一个功能齐全的认证工作流程,如下所示:
图 5.2:用户实体
描述的 用户 实体与我们最初的实体模型略有不同。实体模型反映了数据在数据库中的存储方式。
实体是用户记录的扁平化(或简化)表示。即使是一个扁平化的实体也包含复杂对象,如 姓名,具有首字母、中间名和姓氏等属性。此外,并非所有属性都是必需的。此外,在与认证系统和其他 API 交互时,我们可能会收到不完整、错误或恶意构造的数据,因此我们的代码必须有效地处理 null 和 undefined 变量。
接下来,让我们看看如何利用 TypeScript 运算符有效地处理意外数据。
TypeScript 的安全数据处理运算符
JavaScript 是一种动态类型语言。在运行时,执行我们代码的 JavaScript 引擎,如 Chrome 的 V8,不知道我们使用的变量的类型。因此,引擎必须推断类型。我们可以有基本类型,如 boolean、number、array 或 string,或者我们可以有复杂类型,这本质上是一个 JSON 对象。此外,变量可以是 null 或 undefined。从广义上讲,undefined 表示尚未声明或初始化的某物,而 null 表示已声明变量值的故意缺失。
在强类型语言中,undefined 的概念不存在。基本类型有默认值,如 number 是零或 string 是空字符串。然而,复杂类型可以是 null。null 引用意味着变量已定义,但后面没有值。
null 引用的发明者 Tony Hoare 称其为他的“十亿美元的错误”。
TypeScript 将强类型语言的观念引入 JavaScript,因此它必须在两个世界之间架起桥梁。因此,TypeScript 定义了 null、undefined、any 和 never 等类型,以理解 JavaScript 的类型语义。我在 进一步阅读 部分添加了相关 TypeScript 文档的链接,以便更深入地了解 TypeScript 类型。
如 TypeScript 文档所述,TypeScript 将 null 和 undefined 区分开来,以匹配 JavaScript 的语义。例如,联合类型 string | null 与 string | undefined 和 string | undefined | null 是不同的类型。
另一个细微差别是:使用 == 和 === 来检查一个值是否等于 null。使用双等号运算符,检查 foo != null 表示 foo 已定义且不是 null。然而,使用三等号运算符,foo !== null 表示 foo 不是 null 但可能是 undefined。然而,这两个运算符并没有考虑变量的真值,这包括空字符串的情况。
这些细微差别对编写代码的方式有很大影响,尤其是在使用 --strict 选项创建 Angular 应用程序时应用严格的 TypeScript 规则。重要的是要记住,TypeScript 是一个编译时工具,而不是运行时工具。在运行时,我们仍在处理动态类型语言的现实。仅仅因为我们声明了一个类型是字符串,并不意味着我们会收到一个字符串。
接下来,让我们看看如何处理与处理意外值相关的问题。
null 和 undefined 检查
当与其他库一起工作或处理来自应用程序外部发送或接收的信息时,你必须处理接收到的变量可能是 null 或 undefined 的现实。
在您的应用程序之外意味着处理用户输入,从 cookie 或 localStorage 读取,从路由器获取 URL 参数,或通过 HTTP 进行 API 调用,仅举几个例子。
在我们的代码中,我们主要关注变量的真值。这意味着变量已被定义,不为空,如果它是一个基本类型,它具有非默认值。给定一个 string,我们可以通过简单的 if 语句来检查 string 是否为真值:
**example**
const foo: string = undefined
if(foo) {
console.log('truthy')
} else {
console.log('falsy')
}
如果 foo 是 null、undefined 或一个空字符串,变量将被视为假值。对于某些情况,我们可以使用条件或三元运算符而不是 if-else。
条件或三元运算符
条件或三元运算符具有 ?: 语法。在问号的左侧,运算符接受一个条件表达式。在冒号的右侧,我们提供真值和假值的输出:conditional ? true-outcome : false-outcome。条件或三元运算符是表示 if-else 条件的紧凑方式,并且可以非常有助于提高代码库的可读性。这个运算符不是 if-else 块的替代品,但在使用 if-else 条件的输出时非常有用。
考虑以下示例:
**example**
const foo: string = undefined
let result = ''
if(foo) {
result = 'truthy'
} else {
result = 'falsy'
}
console.log(result)
可以将前面的 if-else 块重写为:
**example**
const foo: string = undefined
console.log(foo ? 'truthy' : 'falsy')
在这种情况下,条件或三元运算符使代码更加紧凑且易于理解。另一个常见场景是返回一个默认值,其中变量为假值。
接下来,我们考虑空合并运算符。
空合并运算符
空合并运算符是 ||。这个运算符在条件表达式的真值与条件表达式本身相同时,可以避免重复。
考虑以下示例,如果 foo 被定义,我们希望使用 foo 的值,但如果它是 undefined,我们需要一个默认值 'bar':
**example**
const foo: string = undefined
console.log(foo ? foo : 'bar')
如您所见,foo 被重复了两次。我们可以通过使用空合并运算符来避免重复:
**example**
const foo: string = undefined
console.log(foo || 'bar')
因此,如果 foo 是 undefined、null 或一个空字符串,将输出 bar。否则,将使用 foo 的值。但在某些情况下,我们只需要在值是 undefined 或 null 时使用默认值。
让我们来看看空合并运算符。
空合并运算符
空合并运算符是 ??。这个运算符与空合并运算符类似,但有一个关键的区别。当处理来自 API 或用户输入的数据时,检查变量的真值可能不足以确定一个空字符串是否为有效值。正如我们在本节前面所讨论的,检查 null 和 undefined 并不像看起来那么简单。然而,我们知道通过使用双等号运算符,我们可以确保 foo 被定义且不为空:
**example**
const foo: string = undefined
console.log(foo != null ? foo : 'bar')
在前面的例子中,如果foo是一个空字符串或另一个值,我们将得到foo输出的值。如果是null或undefined,我们将得到'bar'。通过使用空值合并运算符,我们可以以更紧凑的方式完成这项工作:
**example**
const foo: string = undefined
console.log(foo ?? 'bar')
前面的代码将产生与上一个例子相同的结果。然而,当处理复杂对象时,我们需要考虑它们的属性是否是null或undefined。为此,我们将考虑使用可选链运算符。
可选链
可选链运算符是?。它类似于 Angular 的安全导航运算符。可选链确保在尝试访问子属性或调用函数之前,变量或属性已被定义且不是null。因此,foo?.bar?.callMe()这个语句在没有抛出错误的情况下执行,即使foo或bar是null或undefined。
考虑一下user实体,它有一个name对象,包含first、middle和last属性。让我们看看如何使用空值合并运算符安全地为中间名提供一个空字符串的默认值:
**example**
const user = {
name: {
first: 'Doguhan',
middle: null,
last: 'Uluca'
}
}
console.log((user && user.name && user.name.middle) ?? '')
如你所见,在访问子属性之前,我们需要检查父对象是否是truthy。如果middle是null,则输出一个空字符串。可选链使这项任务变得更简单:
**example**
console.log(user?.name?.middle ?? '')
通过结合使用可选链和空值合并运算符,我们可以消除重复,并交付出健壮的代码,能够有效地处理 JavaScript 动态运行时的现实。
因此,在设计你的代码时,你必须决定是否在你的逻辑中引入 null 的概念,或者使用像空字符串这样的默认值。在下一节中,当我们实现用户实体时,你将看到这些选择是如何发挥作用的。到目前为止,我们只使用了接口来定义我们数据的形式。接下来,让我们构建用户实体,利用面向对象编程的概念,如类、枚举和抽象来实现它,以及一个认证服务。
让我们从简单开始,看看这些模式如何在 JavaScript 类和 TypeScript 基础知识的环境中实现。
实现数据实体和接口
在本节中,我将演示你如何在你的代码设计中使用类来定义和封装你的模型的行为,例如User类。在本章的后面部分,你将看到使用抽象基类的类继承的例子,这允许我们标准化我们的实现,并以干净、易于维护的方式重用基本功能。
我必须指出,面向对象编程(OOP)具有非常实用的模式,这些模式可以提高你代码的质量;然而,如果你过度使用它,那么你将开始失去 JavaScript 动态、灵活和功能性的好处。
有时候,你只需要一个文件中的几个函数,你会在整本书中看到这样的例子。
展示类价值的一个好方法就是标准化创建默认User对象的过程。我们需要这样做,因为BehaviorSubject对象需要用默认对象初始化。最好在一个地方完成这个操作,而不是在多个地方复制粘贴相同的实现。让User对象拥有这个功能而不是由 Angular 服务创建默认User对象是非常有意义的。所以,让我们实现一个User类来实现这个目标。
类、接口和枚举
如前所述,我们只使用接口来表示数据。我们仍然希望在传递数据到各个组件和服务时继续使用接口。接口非常适合描述实现具有哪些属性或函数,但它们对这些属性或函数的行为没有任何暗示。
在 ES2015(ES6)中,JavaScript 获得了对类的原生支持,这是面向对象编程范式的一个关键概念。类是行为的实际实现。与文件中只包含函数集合相比,类可以正确地封装行为。然后可以使用 new 关键字将类实例化为对象。
TypeScript 采用了 ES2015(及以后)的类实现,并引入了必要的概念,如抽象类、私有、受保护和公共属性,以及接口,以便能够实现面向对象编程模式。
我们将首先定义所需数据实体的枚举和接口,利用 TypeScript 的两大最佳特性。
接口帮助我们实践 SOLID 设计原则中的依赖倒置原则:依赖于抽象,而不是具体实现。这意味着在组件或服务之间,传递对象的接口(一个实例化的类)而不是对象本身会更好。这就是为什么我们定义的每个类都将实现一个接口。此外,接口通常是你在新项目中开始编码的第一件事,使用它们来实现你的原型和 API 集成。
枚举有助于确保另一个重要规则:永远不要使用字符串字面量。枚举功能强大且出色。
让我们直接进入并定义所需的接口和枚举:
-
在
src/app/auth/auth.enum.ts位置定义用户角色为enum:**src/app/auth/auth.****enum****.****ts** export enum Role { None = 'none', Clerk = 'clerk', Cashier = 'cashier', Manager = 'manager', } -
在
src/app/user/user文件夹下创建一个user.ts文件。 -
在
user.ts文件中定义一个名为IUser的新接口:**src/app/user/user/user.****ts** import { Role } from '../../auth/auth.enum' export interface IUser { _id: string email: string name: IName picture: string role: Role | string userStatus: boolean dateOfBirth: Date | null | string level: number address: { line1: string line2?: string city: string state: string zip: string } phones: IPhone[] }注意,接口上定义的每个复杂属性也可以表示为
string。在传输过程中,所有对象都使用JSON.stringify()转换为字符串。不包含任何类型信息。我们还利用接口在内存中表示Class对象,这些对象可以具有复杂类型。因此,我们的接口属性必须使用联合类型反映这两种情况。例如,role可以是Role类型或string。同样,dateOfBirth可以是Date或string。我们将
address定义为内联类型,因为我们在这个类之外不使用地址的概念。相比之下,我们将IName定义为其自己的接口,因为在第八章“食谱 - 可重用性、表单和缓存”中,我们将实现一个单独的组件来处理名称。我们还定义了一个单独的接口来处理电话,因为它们被表示为数组。在开发表单时,我们需要能够在模板代码中引用数组的单个元素,例如IPhone。通常,在接口名称前加上大写的
I,以便于识别。不用担心;在 Android 手机上使用IPhone接口没有兼容性问题! -
在
user.ts中定义IName和IPhone接口,并实现PhoneType枚举:**src/app/user/user/user.****ts** export interface IName { first: string middle?: string last: string } export enum PhoneType { None = 'none', Mobile = 'mobile', Home = 'home', Work = 'work', } export interface IPhone { type: PhoneType digits: string id: number }注意,在
PhoneType枚举中,我们明确地定义了string值。默认情况下,enum值在键入时会转换为字符串,这可能导致数据库中存储的值与开发者选择拼写变量名的方式不同步,从而导致问题。通过明确和全部小写的值,我们降低了出现错误的风险。 -
接下来,定义实现
IUser接口的User类:**src/app/user/user/user.****ts** export class User implements IUser { constructor( // tslint:disable-next-line: variable-name public _id = '', public email = '', public name = { first: '', middle: '', last: '' } as IName, public picture = '', public role = Role.None, public dateOfBirth: Date | null = null, public userStatus = false, public level = 0, public address = { line1: '', city: '', state: '', zip: '', }, public phones: IPhone[] = [] ) {} static Build(user: IUser) { if (!user) { return new User() } return new User( user._id, user.email, user.name, user.picture, user.role as Role, typeof user.dateOfBirth === 'string' ? new Date(user.dateOfBirth) : user.dateOfBirth, user.userStatus, user.level, user.address, user.phones ) } }注意,通过在构造函数中将所有属性定义为
public属性并赋予默认值,我们一举两得;否则,我们需要分别定义属性并单独初始化它们。这样,我们实现了简洁的实现。使用静态的
Build函数,我们可以快速用从服务器接收到的数据填充对象。我们还可以实现toJSON()函数来定制对象在发送到服务器前的序列化行为。但在那之前,让我们添加一个计算属性。我们可以在模板或通知消息中使用计算属性方便地显示由多个部分组成的值。一个很好的例子是从
name对象中提取全名作为User类中的一个属性。用于组装全名的计算属性封装了组合首名、中名和姓氏的逻辑,这样你就不必在多个地方重写这个逻辑,遵循 DRY 原则!
-
在
User类中实现fullName属性的 getter:**src/app/user/user/user.****ts** export class User implements IUser { ... **public****get****fullName****():** **string** **{** **if** **(!****this****.****name****) {** **return****''** **}** **if** **(****this****.****name****.****middle****) {** **return** **`****${****this****.name.first}****${****this****.name.middle}****${****this****.name.last}****`** **}** **return****`****${****this****.name.first}****${****this****.name.last}****`** **}** } -
将
fullName添加到IUser中作为一个可选的readonly属性:**src/app/user/user/user.****ts** export interface IUser { ... readonly fullName?: string }你现在可以通过
IUser接口使用fullName属性。 -
实现序列化函数:
**src/app/user/user/user.****ts** export class User implements IUser { ... **toJSON****():** **object** **{** **const** **serialized =** **Object****.****assign****(****this****)** **delete** **serialized.****_id** **delete** **serialized.****fullName** **return** **serialized** **}** }
注意,在序列化对象时,我们删除了_id和fullName字段。这些是我们不希望存储在数据库中的值。fullName字段是一个计算属性,因此不需要存储。_id通常在GET或PUT调用中作为参数传递,以定位记录。这避免了可能导致的错误,这些错误可能会导致覆盖现有对象的id字段。
现在我们已经实现了User data实体,接下来让我们实现认证服务。
利用面向对象概念的可重用服务
与 RxJS 所支持的响应式编程风格相比,OOP(面向对象编程)是一种命令式编程风格。类是 OOP 的基础,而使用 RxJS 的观察者(observables)在响应式编程中扮演着同样的角色。
我鼓励你熟悉 OOP 术语。请参阅进一步阅读部分,了解一些有用的资源。你应该熟悉:
-
类与对象
-
组合(接口)
-
封装(私有、受保护、公共属性,以及属性获取器和设置器)
-
多态(继承、抽象类和方法重写)
如你所知,Angular 使用 OOP 模式来实现组件和服务。例如,接口实现了生命周期钩子,如OnInit。我们的目标是设计一个灵活的认证服务,它可以实现多个认证提供者。在第六章,实现基于角色的导航中,我们将实现一个内存提供者和一个 Google Firebase 提供者。在第七章,与 REST 和 GraphQL API 交互中,我们将实现两个自定义提供者以与我们的后端交互,并了解基于角色的访问控制(RBAC)是如何实现的。
通过声明一个抽象基类,我们可以描述我们应用程序的常见登录和注销行为,因此当我们实现另一个认证提供者时,我们不需要重新设计我们的应用程序。
此外,我们可以声明抽象函数,我们的基类的实现者必须实现这些函数,以强制我们的设计。任何实现基类的类都将获得基类中实现代码的好处,因此我们不需要在两个不同的地方重复相同的逻辑。
以下类图反映了我们抽象的AuthService的架构和继承层次结构:
图 5.3:AuthService 继承结构
AuthService实现了IAuthService接口,如下所示:
export interface IAuthService {
readonly authStatus$: BehaviorSubject<IAuthStatus>
readonly currentUser$: BehaviorSubject<IUser>
login(email: string, password: string): Observable<void>
logout(clearToken?: boolean): void
getToken(): string
}
接口反映了服务公开的属性。服务提供认证状态作为authStatus$观察者,当前用户作为currentUser$,并提供三个函数,login、logout和getToken。
AuthService需要从另一个名为CacheService的服务中获取缓存功能。我们不是通过继承来整合缓存功能,而是将其注入到基类中。由于AuthService是一个抽象类,它不能独立使用,因此我们将实现三个认证提供者,即图示下方的InMemoryAuthService、FirebaseAuthService和CustomAuthService。
组合优于继承,因此你必须确保你正确地使用了继承。继承描述了一个 is-a 关系,而组合描述了一个 has-a 关系。在这种情况下,我们使用了正确的继承和组合的混合,因为FirebaseAuthService是AuthService,而AuthService有一个CacheService。
注意,所有三个认证服务都实现了所有抽象函数。此外,FirebaseAuthService 覆盖了基类的 logout 函数以实现其自己的行为。所有三个类都继承自同一个抽象类并公开相同的公共接口。所有三个都将执行相同的认证工作流程,针对不同的认证服务器。
内存中的认证服务不与服务器通信。此服务仅用于演示目的。它实现了假的 JWT 编码,因此我们可以演示 JWT 生命周期的工作方式。
让我们从创建认证服务开始。
创建一个认证服务
我们将首先创建抽象的认证服务和内存中的服务:
-
添加一个认证服务:
$ npx ng g s auth --flat false $ npx ng g s auth/inMemoryAuth --skip-tests -
将
in-memory-auth.service.ts重命名为auth.in-memory.service.ts,以便在文件资源管理器中将不同的认证提供者视觉上分组在一起。 -
移除
auth.service.ts中的@Injectable()装饰器,但保留在auth.in-memory.service.ts上。 -
确保在
app.module.ts中提供了authService,并且使用InMemoryAuthService而不是抽象类:**src/app/app.****module****.****ts** **import** **{** **AuthService** **}** **from****'./auth/auth.service'** **import** **{** **InMemoryAuthService** **}** **from****'./auth/auth.in-memory.service'** ... providers: [ **{** **provide****:** **AuthService****,** **useClass****:** **InMemoryAuthService** **},** ... ]
为服务创建一个单独的文件夹,可以组织与认证相关的各种组件,例如用户角色的 enum 定义。此外,我们还将能够将 authService 模拟器添加到同一个文件夹中,以进行自动化测试。
实现一个抽象的认证服务
现在,让我们构建一个抽象的认证服务,该服务将协调登录和注销,同时封装管理 JWT、认证状态和有关当前用户的信息的逻辑。通过利用抽象类,我们应该能够针对任何认证提供者实现自己的认证服务,而无需修改应用程序的内部行为。
我们将要演示的抽象认证服务可以实现丰富和复杂的流程。这是一个可以无缝集成到您的应用程序中的解决方案,无需修改内部逻辑。因此,它是一个复杂的解决方案。
此认证服务将使我们能够演示使用电子邮件和密码进行登录、缓存以及基于认证状态和用户角色的条件导航概念:
-
首先安装一个 JWT 解码库,以及为了模拟认证的 JWT 编码库:
$ npm install jwt-decode $ npm install -D @types/jwt-decode -
实现一个
IAuthStatus接口以存储解码后的用户信息,一个辅助接口,以及默认安全的defaultAuthStatus:**src/app/auth/auth.****service****.****ts** import { Role } from './auth.enum' ... export interface IAuthStatus { isAuthenticated: boolean userRole: Role userId: string } export interface IServerAuthResponse { accessToken: string } export const defaultAuthStatus: IAuthStatus = { isAuthenticated: false, userRole: Role.None, userId: '', } ...IAuthStatus是一个接口,它代表了从认证服务接收到的典型 JWT 的结构。它包含有关用户及其角色的最小信息。认证状态对象可以附加到每个 API 调用的头部,以验证用户的身份。认证状态可以可选地缓存在localStorage中以记住用户的登录状态;否则,他们每次刷新页面时都需要重新输入密码。在前面的实现中,我们假设默认角色为
None,如Role枚举中定义。通过默认不给用户分配任何角色,我们遵循最小权限访问模型。用户正确的角色将在他们使用从 auth API 收到的信息成功登录后设置。 -
在
auth.service.ts中定义IAuthService接口:**src/app/auth/auth.service.ts** export interface IAuthService { readonly authStatus$: BehaviorSubject<IAuthStatus> readonly currentUser$: BehaviorSubject<IUser> login(email: string, password: string): Observable<void> logout(clearToken?: boolean): void getToken(): string } -
将
AuthService实现为abstract类,如下所示:export abstract class AuthService -
使用 VS Code 的快速修复功能实现接口
IAuthService:**src/app/auth/auth.****service****.****ts** export abstract class AuthService **implements****IAuthService** { authStatus$: BehaviorSubject<IAuthStatus> currentUser$: BehaviorSubject<IUser> **constructor****() {}** login(email: string, password: string): Observable<void> { throw new Error('Method not implemented.') } logout(clearToken?: boolean): void { throw new Error('Method not implemented.') } getToken(): string { throw new Error('Method not implemented.') } } -
将
authStatus$和currentUser$属性实现为readonly并用它们的默认值初始化我们的数据锚点:**src/app/auth/auth.****service****.****ts** import { IUser, **User** } from '../user/user/user' ... export abstract class AuthService implements IAuthService { **readonly** authStatus$ = **new****BehaviorSubject****<****IAuthStatus****>(defaultAuthStatus)** **readonly** currentUser$ = **new****BehaviorSubject****<****IUser****>(****new****User****())** ... }
注意,我们移除了属性的类型定义。相反,我们让 TypeScript 从初始化中推断类型。
你必须始终将你的数据锚点声明为 readonly,这样你就不会意外地通过将数据锚点重新初始化为新的 BehaviorSubject 来覆盖数据流,这样做会使任何先前的订阅者成为孤儿,导致内存泄漏,这会有许多意想不到的后果。
所有实现 IAuthService 的实现者必须能够登录用户,转换从服务器返回的令牌,以便我们可以读取和存储它,支持访问当前用户和认证状态,并提供一种注销用户的方式。我们已经成功添加了公共方法的函数,并为我们的数据锚点实现了默认值,为我们的应用程序的其他部分创建了钩子。但到目前为止,我们只定义了我们的服务可以做什么,而没有定义它是如何做到的。
总是细节决定成败,难点在于“如何”。抽象函数可以帮助我们在应用程序的服务中完成工作流程的实现,同时将必须实现外部 API 的服务部分留空。
抽象函数
实现抽象类的认证服务应该能够支持任何类型的认证提供者和任何类型的令牌转换,同时能够修改行为,如用户检索逻辑。我们必须能够实现登录、注销、令牌和认证状态管理,而不需要实现对特定服务的调用。
通过定义抽象函数,我们可以声明一系列必须实现一组给定输入和输出的方法——一个没有实现的签名。然后我们可以使用这些抽象函数来编排我们的认证工作流程的实现。
开放/封闭原则推动了我们的设计目标。AuthService 将通过其能够扩展以与任何基于令牌的认证提供者一起工作的能力而开放,但它对修改是封闭的。一旦我们完成了 AuthService 的实现,我们就不会需要修改其代码来添加额外的认证提供者。
现在,我们需要定义我们的认证提供者必须实现的抽象函数,如本章前面 图 5.3 所示:
-
authProvider(email, password):Observable<IServerAuthResponse>可以通过提供者登录并返回标准化的IServerAuthResponse -
transformJwtToken(token):IAuthStatus可以将提供者返回的令牌标准化为IAuthStatus接口 -
getCurrentUser():Observable<User>可以检索已登录用户的用户资料
然后,我们可以在 login、logout 和 getToken 方法中使用这些函数来实现身份验证工作流程:
-
将派生类应该实现的抽象方法定义为受保护的属性,以便在派生类中可访问,但不是公开的:
**src/app/auth/auth.****service****.****ts** ... **export****abstract****class****AuthService****implements****IAuthService** **{** **protected****abstract****authProvider****(** **email****:** **string****,** **password****:** **string** **):** **Observable****<****IServerAuthResponse****>** **protected****abstract****transformJwtToken****(****token****:** **unknown****):** **IAuthStatus** **protected****abstract****getCurrentUser****():** **Observable****<****User****>** ... }利用这些模拟的方法,我们现在可以实现一个登录方法来登录用户并检索当前登录用户,更新
authStatus$和currentUser$数据流。 -
在我们继续之前,实现一个
transformError函数来处理不同类型的错误,如HttpErrorResponse和string,并将它们提供在可观察的流中。在src/app/common下的新文件common.ts中创建transformError函数:**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}` } else if (error instanceof Error) { errorMessage = error.message } return throwError(errorMessage) } -
在
auth.service.ts中实现login方法:**src/app/auth/auth.****service****.****ts** import * **as** decode from 'jwt-decode' import { transformError } from '../common/common' ... login(email: string, password: string): Observable<void> { const loginResponse$ = this.authProvider(email, password) .pipe( map((value) => { const token = decode(value.accessToken) return this.transformJwtToken(token) }), tap((status) => this.authStatus$.next(status)), filter((status: IAuthStatus) => status.isAuthenticated), flatMap(() => this.getCurrentUser()), map(user => this.currentUser$.next(user)), catchError(transformError) ) loginResponse$.subscribe({ error: err => { this.logout() return throwError(err) }, }) return loginResponse$ }login方法通过调用authProvider并传入email和password信息来封装正确的操作顺序,然后解码接收到的 JWT,进行转换,并更新authStatus$。接着,只有当status.isAuthenticated为true时,才会调用getCurrentUser()。之后,更新currentUser$,最后,我们使用自定义的transformError函数来捕获任何错误。我们通过在它上面调用
subscribe来激活可观察的流。在出现错误的情况下,我们调用logout()以保持应用程序的正确状态,并通过使用throwError重新抛出错误,将错误冒泡到login的消费者。现在,需要实现相应的
logout函数。在登录尝试失败的情况下,或者在检测到未经授权的访问尝试时,都会触发退出。我们可以通过使用路由器身份验证守卫来检测未经授权的访问尝试,当用户在应用程序中导航时,这是本章后面将要讨论的一个主题。 -
实现退出方法:
**src/app/auth/auth.****service****.****ts** ... logout(clearToken?: boolean): void { setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0) }
我们通过在 authStatus$ 流中推送 defaultAuthStatus 作为下一个值来注销。注意 setTimeout 的使用,它允许我们在应用程序的核心元素同时更改状态时避免时序问题。
考虑一下 login 方法是如何遵循开放/封闭原则的。该方法通过抽象函数 authProvider、transformJwtToken 和 getCurrentUser 对扩展开放。通过在派生类中实现这些函数,我们可以外部提供不同的身份验证提供者,而不需要修改 login 方法。因此,方法的实现保持对修改的封闭,从而遵循开放/封闭原则。
创建抽象类真正的价值在于能够以可扩展的方式封装通用功能。
目前您可以忽略getToken函数,因为我们还没有缓存我们的 JWT。没有缓存,用户每次刷新页面时都需要登录。让我们接下来实现缓存。
使用 localStorage 的缓存服务
我们必须能够缓存已登录用户的认证状态。如前所述,否则,每次刷新页面时,用户都必须通过登录流程。我们需要更新AuthService以持久化认证状态。
存储数据主要有三种方式:
-
cookie -
localStorage -
sessionStorage
虽然 cookies 有其用例,但不应该用来存储安全数据,因为它们可以被恶意行为者嗅探或窃取。此外,cookies 只能存储 4KB 的数据,并且可以设置过期时间。
localStorage和sessionStorage相似。它们是受保护和隔离的浏览器端存储,允许为您的应用程序存储更多的数据。与 cookies 不同,您不能为存储在任一存储中的值设置过期日期和时间。存储在任一存储中的值在页面重新加载和恢复时仍然存在,这使得它们比 cookies 更适合缓存信息。
localStorage和sessionStorage之间的主要区别在于值如何在浏览器标签页之间持久化。使用sessionStorage,存储的值在浏览器标签页或窗口关闭时被删除。然而,localStorage在重启后仍然存在。在大多数情况下,用户登录的缓存可以从几分钟到一个月或更长时间,具体取决于您的业务,因此依赖于用户是否关闭浏览器窗口并不是很有用。通过这个过程排除,我更喜欢localStorage,因为它具有隔离性和长期存储能力。
JWT 可以被加密并包含过期时间的戳。从理论上讲,这抵消了 cookies 和localStorage的弱点。如果正确实现,任一选项都应安全用于 JWT,但localStorage仍然更受欢迎。
让我们先实现一个缓存服务,它可以为我们应用程序提供一个集中的缓存方法。然后我们可以从这个服务中派生出我们的认证信息缓存:
-
首先创建一个抽象的
cacheService,它封装了缓存的方法:**src/app/common/cache.****service****.****ts** @Injectable({ providedIn: 'root' }) export class CacheService { protected getItem<T>(key: string): T | null { const data = localStorage.getItem(key) if (data != null) { try { return JSON.parse(data) } catch (error) { console.error('Parsing error:', error) return null } } 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,这将使我们能够在下一节中实现 JWT 的缓存:**src/app/auth/auth.****service****.****ts** ... export abstract class AuthService implements IAuthService { protected readonly cache = inject(CacheService) ... }
让我们通过一个示例来了解如何通过缓存authStatus对象的值来使用基类的功能:
**example**
authStatus$ = new BehaviorSubject<IAuthStatus>(
this.getItem('authStatus') ?? defaultAuthStatus
)
constructor() {
this.authStatus$.pipe(
tap(authStatus => this.cache.setItem('authStatus', authStatus))
)
}
示例中展示的技术利用了 RxJS 的可观察流,在authStatus$的值发生变化时更新缓存。你可以使用这种模式持久化任何类型的数据,而不会让你的业务逻辑被缓存代码所杂乱。在这种情况下,我们不需要更新login函数来调用setItem,因为它已经调用了this.authStatus.next,我们只需接入数据流。这有助于保持无状态并避免副作用,通过解耦函数来实现。
注意,我们还在初始化BehaviorSubject时使用了getItem函数。使用空值合并运算符,我们只有在缓存的数据不是undefined或null时才使用它。否则,我们提供默认值。
你可以在setItem和getItem函数中实现自己的自定义缓存过期方案,或者利用第三方创建的服务。
然而,为了额外的安全层,我们不会缓存authStatus对象。相反,我们只会缓存编码后的 JWT,它只包含足够的信息,以便我们可以验证发送到服务器的请求。
在第七章的“实现 JWT 身份验证”部分,我们在“与 REST 和 GraphQL API 一起工作”中讨论了您应该如何加密和验证 JWT 令牌的有效性,以避免基于令牌的攻击。
理解基于令牌的认证工作方式对于避免泄露敏感信息至关重要。回顾本章早些时候的 JWT 生命周期,以加深你的理解。
接下来,让我们缓存令牌。
缓存 JWT
让我们更新身份验证服务,使其能够缓存令牌:
-
更新
AuthService以能够设置、获取和清除令牌,如下所示:**src/app/auth/auth.****service****.****ts** ... protected setToken(jwt: string) { this.cache.setItem('jwt', jwt) } getToken(): string { return this.cache.getItem('jwt') ?? '' } protected clearToken() { this.cache.removeItem('jwt') } -
在
login期间调用clearToken和setToken,在logout期间调用clearToken,如下所示:**src/app/auth/auth.****service****.****ts** ... login(email: string, password: string): Observable<void> { **this****.****clearToken****()** const loginResponse$ = this.authProvider(email, password) .pipe( map(value => { **this****.****setToken****(value.****accessToken****)** const token = decode(value.accessToken) return this.transformJwtToken(token) }), tap((status) => this.authStatus$.next(status)), ... } logout(clearToken?: boolean) { **if** **(clearToken) {** **this****.****clearToken****()** **}** setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0) }
每个后续请求都将包含在请求头中的 JWT。你应该确保每个 API 都检查并验证收到的令牌。例如,如果用户想要访问他们的个人资料,AuthService将验证令牌以检查用户是否经过身份验证;然而,还需要进一步的数据库调用来检查用户是否有权查看数据。这确保了对用户访问系统的独立确认,并防止了对未过期令牌的滥用。
如果一个经过身份验证的用户调用了一个他们没有适当授权的 API(比如说,如果一名职员想要获取所有用户的列表),那么AuthService将返回一个falsy状态,客户端将收到一个403 禁止的响应,这将被显示为用户的一个错误消息。
用户可以使用过期的令牌进行请求;当这种情况发生时,会向客户端发送一个401 未授权的响应。作为一个好的用户体验实践,我们应该自动提示用户重新登录,并允许他们在不丢失任何数据的情况下继续他们的工作流程。
总结来说,真正的安全性是通过强大的服务器端实现来实现的。任何客户端实现主要是为了在良好的安全实践周围提供良好的用户体验。
内存中的身份验证服务
现在,让我们实现一个具体版本的身份验证服务,我们可以使用它:
-
首先安装一个 JWT 解码库,以及为了模拟身份验证,一个 JWT 编码库:
$ npm install fake-jwt-sign -
扩展
AuthService抽象类:**src/app/auth/auth.****in****-memory.****service****.****ts** import { AuthService } from './auth.service' @Injectable({ providedIn: 'root' }) export class InMemoryAuthService **extends****AuthService** { constructor() { super() **console****.****warn****(** **'You're using the InMemoryAuthService. Do not use this service in production.'** **)** } … } -
实现一个模拟的
authProvider函数,模拟身份验证过程,包括动态创建一个模拟 JWT:**src/app/auth/auth.****in****-memory.****service****.****ts** import { sign } from 'fake-jwt-sign'//For InMemoryAuthService only ... protected authProvider( email: string, password: string ): Observable<IServerAuthResponse> { email = email.toLowerCase() if (!email.endsWith('@test.com')) { return throwError( 'Failed to login! Email needs to end with @test.com.' ) } const authStatus = { isAuthenticated: true, userId: this.defaultUser._id, userRole: email.includes('cashier') ? Role.Cashier : email.includes('clerk') ? Role.Clerk : email.includes('manager') ? Role.Manager : Role.None, } as IAuthStatus this.defaultUser.role = authStatus.userRole const authResponse = { accessToken: sign(authStatus, 'secret', { expiresIn: '1h', algorithm: 'none', }), } as IServerAuthResponse return of(authResponse) } ...authProvider在服务中实现了原本应该在服务器端实现的方法,这样我们就可以方便地在微调身份验证工作流程的同时实验代码。提供者使用临时的fake-jwt-sign库创建并签名 JWT,这样我也可以演示如何处理一个正确形成的 JWT。不要将
fake-jwt-sign依赖项打包到你的 Angular 应用程序中,因为它意味着是服务器端代码。相比之下,一个真实的身份验证提供者将包括一个发送到服务器的
POST调用。请参阅以下示例代码:**example** private exampleAuthProvider( email: string, password: string ): Observable<IServerAuthResponse> { return this.httpClient.post<IServerAuthResponse>( `${environment.baseUrl}/v1/login`, { email: email, password: password } ) }这相当直接,因为困难的工作已经在服务器端完成。这个调用也可以发送给第三方身份验证提供者,我将在本章后面的 Firebase 身份验证食谱中介绍。
注意,URL 路径中的 API 版本
v1是在服务中定义的,而不是作为baseUrl的一部分。这是因为每个 API 可以独立更改版本。登录可能长时间保持为v1,而其他 API 可能升级到v2、v3等等。 -
实现
transformJwtToken将是微不足道的,因为登录函数为我们提供了一个符合IAuthStatus的令牌:**src/app/auth/auth.****in****-memory.****service****.****ts** protected transformJwtToken(token: IAuthStatus): IauthStatus { return token } -
最后,实现
getCurrentUser,它应该返回一些默认用户:**src/app/auth/auth.****in****-memory.****service****.****ts** protected getCurrentUser(): Observable<User> { return of(this.defaultUser) }接下来,将
defaultUser作为私有属性提供给类;以下是我创建的一个示例。 -
在
InMemoryAuthService类中添加一个私有的defaultUser属性:**src/app/auth/auth.****in****-memory.****service****.****ts** import { PhoneType, User } from '../user/user/user' ... private defaultUser = User.Build({ _id: '5da01751da27cc462d265913', email: 'duluca@gmail.com', name: { first: 'Doguhan', last: 'Uluca' }, picture: 'https://secure.gravatar.com/ avatar/7cbaa9afb5ca78d97f3c689f8ce6c985', role: Role.Manager, dateOfBirth: new Date(1980, 1, 1), userStatus: true, address: { line1: '101 Sesame St.', city: 'Bethesda', state: 'Maryland', zip: '20810', }, level: 2, phones: [ { id: 0, type: PhoneType.Mobile, digits: '5555550717', }, ], })
恭喜!你已经实现了一个具体但仍然模拟的身份验证服务。现在你已经有了一个内存中的身份验证服务,确保运行你的 Angular 应用程序并且没有错误出现。
让我们通过实现一个简单的登录和注销功能来测试我们的身份验证服务,这些功能可以通过 UI 访问。
简单登录
在我们实现一个功能齐全的 login 组件之前,让我们将预制的登录行为连接到 HomeComponent 中的“登录为管理员”按钮。在我们深入了解交付丰富 UI 组件的细节之前,我们可以测试我们的身份验证服务的功能。
我们的目标是模拟登录为管理员。为了实现这一点,我们需要硬编码一个电子邮件地址和密码进行登录,并在登录成功后,保持导航到 /manager 路由的功能。
注意,在 GitHub 上,本节代码示例位于projects/stage8文件夹结构下的home.component.simple.ts文件中。另一个文件仅用于参考目的,因为本章后面的代码将对此部分进行重大更改。忽略文件名差异,因为它不会影响本节编码。
让我们实现一个简单的登录机制:
-
在
HomeComponent中实现一个使用AuthService的login函数:**src/app/home/home.****component****.****ts** import { AuthService } from '../auth/auth.service' export class HomeComponent implements OnInit { constructor(private authService: AuthService) {} ngOnInit(): void {} login() { this.authService.login('manager@test.com', '12345678') } } -
更新模板以删除
routerLink,而不是调用login函数:**src/app/home/home.****component****.****ts** template: ` <div fxLayout="column" fxLayoutAlign="center center"> <span class="mat-display-2">Hello, Limoncu!</span> <button mat-raised-button color="primary" **(click)="login()"**> Login as Manager </button> </div> `,在成功登录后,我们需要导航到
/manager路由。我们可以通过监听AuthService公开的authStatus$和currentUser$可观察对象来验证我们是否成功登录。如果authStatus$.isAuthenticated为true且currentUser$._id是一个非空字符串,则表示有效的登录。我们可以通过使用 RxJS 的combineLatest运算符来监听这两个可观察对象。在有效的登录条件下,然后我们可以使用filter运算符来响应式地导航到/manager路由。 -
更新
login()函数以实现登录条件,并在成功后导航到/manager路由:**src/app/home/home.****component****.****ts** constructor( private authService: AuthService, **private****router****:** **Router** ) {} login() { this.authService.login('manager@test.com', '12345678') **combineLatest****([** **this****.****authService****.****authStatus$****,** **this****.****authService****.****currentUser$** **])** **.****pipe****(** **filter****(****(****[authStatus, user]****) =>** **authStatus.****isAuthenticated** **&& user?.****_id** **!==** **''** **),** **tap****(****(****[authStatus, user]****) =>** **{** **this****.****router****.****navigate****([****'/manager'****])** **})** **)** **.****subscribe****()** }注意,我们在最后订阅了
combineLatest运算符,这在激活可观察流中是至关重要的。否则,除非其他组件订阅了流,我们的登录操作将保持休眠状态。你只需要激活一次流。 -
现在,测试新的
login功能。验证 JWT 是否已创建并存储在localStorage中,如这里所示:![img/B20960_05_04.png]图 5.4:DevTools 显示应用程序 | 本地存储
你可以在应用程序选项卡下查看本地存储。确保你的应用程序 URL 被突出显示。在步骤 3中,你可以看到我们有一个名为jwt的键,它包含一个看起来有效的令牌。
注意步骤 4和步骤 5,突出显示两个警告,分别建议我们不要在生产代码中使用InMemoryAuthService和fake-jwt-sign包。
使用断点进行调试并逐步执行代码,以更具体地了解HomeComponent、InMemoryAuthService和AuthService如何协同工作以登录用户。
当你刷新页面时,请注意你仍然处于登录状态,因为我们正在本地存储中缓存令牌。
由于我们正在缓存登录状态,我们必须实现一个登出体验来完成认证工作流程。
登出
应用程序工具栏上的登出按钮已经连接到我们之前创建的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>`, }) export class LogoutComponent implements OnInit { constructor(private router: Router, private authService: AuthService) {} ngOnInit() { this.authService.logout(true) this.router.navigate(['/']) } }注意,我们通过将
true传递给logout函数来显式清除 JWT。在调用logout之后,我们将用户导航回主页。 -
测试
logout按钮。 -
验证在登出后本地存储已被清除。
我们已经实现了稳固的登录和注销功能。然而,我们还没有完成我们认证流程的基础。
接下来,我们需要考虑我们的 JWT 的过期状态。
恢复 JWT 会话
如果你每次访问网站时都必须登录 Gmail 或 Amazon,那么用户体验将不会很好。这就是为什么我们缓存 JWT,但如果你永远保持登录状态,用户体验同样糟糕。JWT 有一个过期日期策略,提供商可以选择几分钟甚至几个月来允许你的令牌有效,这取决于安全需求。内存服务创建的令牌在一小时内过期,所以如果用户在那一时间段内刷新浏览器窗口,我们应该尊重有效的令牌,并允许用户继续使用应用程序,而无需要求他们重新登录。
另一方面,如果令牌已过期,我们应该自动将用户导航到登录屏幕,以实现流畅的用户体验。
让我们开始吧:
-
更新
AuthService类以实现一个名为hasExpiredToken的函数来检查令牌是否过期,以及一个名为getAuthStatusFromToken的辅助函数来解码令牌,如下所示:**src/app/auth/auth.****service****.****ts** ... protected hasExpiredToken(): boolean { const jwt = this.getToken() if (jwt) { const payload = decode(jwt) as any return Date.now() >= payload.exp * 1000 } return true } protected getAuthStatusFromToken(): IAuthStatus { return this.transformJwtToken(decode(this.getToken())) }保持你的代码 DRY!将
login()函数更新为使用getAuthStatusFromToken()。 -
更新
AuthService的构造函数以检查令牌的状态:**src/app/auth/auth.****service****.****ts** ... constructor() { super() if (this.hasExpiredToken()) { this.logout(true) } else { this.authStatus$.next(this.getAuthStatusFromToken()) } }如果令牌已过期,我们注销用户并从
localStorage中清除令牌。否则,我们解码令牌并将认证状态推送到数据流中。在这里需要考虑的一个特殊情况是,在恢复会话时也要触发当前用户的重新加载。我们可以通过实现一个新的管道来实现这一点,如果激活,则重新加载当前用户。
-
首先,让我们将
login()函数中现有的用户更新逻辑重构为一个名为getAndUpdateUserIfAuthenticated的私有属性,以便我们可以重用它:**src/app/auth/auth.****service****.****ts** ... export abstract class AuthService implements IAuthService { **private** **getAndUpdateUserIfAuthenticated =** **pipe****(** **filter****(****(****status****:** **IAuthStatus****) =>** **status.****isAuthenticated****),** **flatMap****(****() =>****this****.****getCurrentUser****()),** **map****(****(****user****:** **IUser****) =>****this****.****currentUser$****.****next****(user)),** **catchError****(transformError)** **)** ... login(email: string, password: string): Observable<void> { this.clearToken() const loginResponse$ = this.authProvider(email, password) .pipe( map((value) => { this.setToken(value.accessToken) const token = decode(value.accessToken) return this.transformJwtToken(token) }), tap((status) => this.authStatus$.next(status)), **this****.****getAndUpdateUserIfAuthenticated** ) ... } ... } -
在
AuthService中,定义一个名为resumeCurrentUser$的可观察属性,作为authStatus$的分支,并使用getAndUpdateUserIfAuthenticated逻辑:**src/app/auth/auth.****service****.****ts** ... protected readonly resumeCurrentUser$ = this.authStatus$.pipe( this.getAndUpdateUserIfAuthenticated )一旦
resumeCurrentUser$被激活且status.isAuthenticated为true,则this.getCurrentUser()将被调用,并且currentUser$将被更新。 -
更新
AuthService的构造函数,如果令牌未过期则激活管道:**src/app/auth/auth.****service****.****ts** ... constructor() { if (this.hasExpiredToken()) { this.logout(true) } else { this.authStatus$.next(this.getAuthStatusFromToken()) // To load user on browser refresh, // resume pipeline must activate on the next cycle // Which allows for all services to constructed properly setTimeout(() => this.resumeCurrentUser$.subscribe(), 0) } }
使用前面的技术,我们可以检索最新的用户配置文件数据,而无需处理缓存问题。
要实验令牌过期,我建议在 InMemoryAuthService 中创建一个更快过期的令牌。
如缓存部分之前所演示的,可以使用this.cache.setItem和缓存中的配置文件数据在首次启动时缓存用户配置文件数据。这将提供更快的用户体验,并覆盖用户可能离线的情况。应用启动后,您可以异步获取新鲜的用户数据,并在新数据到来时更新currentUser$。您需要添加额外的缓存并调整getCurrentUser()逻辑以使此功能正常工作。哦,您还需要进行大量的测试!创建高质量的认证体验需要大量的测试。
恭喜!我们已经完成了健壮的认证工作流程的实现!接下来,我们需要将认证与 Angular 的 HTTP 客户端集成,以便将令牌附加到每个请求的 HTTP 头中。
一个 HTTP 拦截器
实现一个 HTTP 拦截器,将 JWT 注入到发送到 API 的每个请求的头部中,并通过提示用户重新登录来优雅地处理认证失败:
-
在
auth下创建一个AuthHttpInterceptor:**src/app/auth/auth.****http****.****interceptor****.****ts** import { HttpHandlerFn, HttpRequest } from '@angular/common/http' import { inject } from '@angular/core' import { Router } from '@angular/router' import { throwError } from 'rxjs' import { catchError } from 'rxjs/operators' import { environment } from 'src/environments/environment' import { UiService } from '../common/ui.service' import { AuthService } from './auth.service' export function AuthHttpInterceptor( req: HttpRequest<unknown>, next: HttpHandlerFn ) { const authService = inject(AuthService) const router = inject(Router) const uiService = inject(UiService) const jwt = authService.getToken() const baseUrl = environment.baseUrl if (req.url.startsWith(baseUrl)) { const authRequest = req.clone({ setHeaders: { authorization: `Bearer ${jwt}` } }) return next(authRequest).pipe( catchError((err) => { uiService.showToast(err.error.message) if (err.status === 401) { router.navigate(['/login'], { queryParams: { redirectUrl: router.routerState.snapshot.url }, }) } return throwError(() => err) }) ) } else { return next(req) } }注意到
AuthService被用来检索令牌,并且在401错误后为login组件设置了redirectUrl。注意到 if 语句
if (req.url.startsWith(baseUrl))过滤掉了所有不是发送到我们 API 的出站请求。这样,我们就不会将 JWT 令牌泄露给外部服务。 -
更新
app.config.ts以提供拦截器:**src/app/app.****config****.****ts** export const appConfig: ApplicationConfig = { providers: [ provideAnimations(), provideHttpClient( **withInterceptors****([****AuthHttpInterceptor****])** ), ... -
确保拦截器将令牌添加到请求中。为此,打开Chrome DevTools | 网络标签,登录,然后刷新页面:
图 5.5:lemon.svg 的请求头
在步骤 4中,您现在可以观察到拦截器的实际操作。对lemon.svg文件的请求在请求头中包含了承载令牌。
现在我们已经实现了认证机制,让我们利用我们编写的所有支持代码,包括动态 UI 组件和条件导航系统,在下一章中创建基于角色的用户体验。
摘要
您现在应该对 JWT 的工作原理、如何使用 TypeScript 进行安全数据处理以及如何构建可扩展的服务有一个稳固的理解。在本章中,我们定义了一个User对象,我们可以从它中提取或将其序列化为 JSON 对象,应用面向对象类设计和 TypeScript 运算符进行安全数据处理。
我们利用面向对象设计原则,使用继承和抽象类来实现一个基本的认证服务,该服务演示了开放/封闭原则。
我们涵盖了基于令牌的认证和 JWT 的基础知识,这样您就不会泄露任何关键用户信息。您学习了缓存和 HTTP 拦截器是必要的,这样用户就不必在每次请求时输入他们的登录信息。在此之后,我们实现了一个内存中的认证服务,它不需要任何外部依赖,这对于测试来说非常好。
在第六章 实现基于角色的导航 中,我们将构建一个动态 UI,使用路由和身份验证守卫,弹性布局媒体查询,Material 组件和服务工厂,以响应应用程序的身份验证状态。我们还将实现 Firebase 身份验证提供者,以便您可以在 Google Firebase 上托管您的应用程序。在第七章 与 REST 和 GraphQL API 一起工作 中,我们将使用两个自定义身份验证提供者将所有内容整合在一起,这些提供者可以针对 LemonMart 服务器进行身份验证,使用 Minimal MEAN 堆栈。
进一步阅读
-
盐值密码散列 - 正确的做法,Defuse 安全,2019;
crackstation.net/hashing-security.htm. -
TypeScript 类;
www.typescriptlang.org/docs/handbook/classes.html. -
TypeScript 基本类型;
www.typescriptlang.org/docs/handbook/basic-types.html. -
TypeScript 高级类型;
www.typescriptlang.org/docs/handbook/advanced-types.html. -
TypeScript 3.7 特性;
www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html. -
身份验证通用指南;
github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Authentication_Cheat_Sheet.md. -
即使您的 API 密钥公开可用,如何保护您的 Firebase 项目;paachu,2019,
medium.com/@impaachu/how-to-secure-your-firebase-project-even-when-your-api-key-is-publicly-available-a462a2a58843.
问题
尽可能地回答以下问题,以确保您已经理解了本章的关键概念,而无需进行任何谷歌搜索。你知道你是否答对了所有问题吗?访问 angularforenterprise.com/self-assessment 获取更多信息:
-
在传输中和静止状态下的安全性是什么?
-
身份验证和授权之间的区别是什么?
-
解释继承和多态。
-
抽象类是什么?
-
抽象方法是什么?
-
解释
AuthService如何遵循开放/封闭原则。 -
JWT 如何验证您的身份?
-
RxJS 的
combineLatest和merge操作符之间的区别是什么? -
路由守卫是什么?
-
服务工厂允许您做什么?
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
packt.link/AngularEnterpise3e