Angular-企业级应用第三版-三-

56 阅读49分钟

Angular 企业级应用第三版(三)

原文:zh.annas-archive.org/md5/0bae576facf6820e0cfce21c539985d0

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:实现基于角色的导航

第五章设计授权和认证中,我们讨论了设计一个有效的认证和授权系统虽然具有挑战性但对于用户满意度至关重要。用户对网络认证系统有很高的期望,任何错误都应该明确传达。随着应用程序的增长,它们的认证核心应该易于维护和扩展,以确保无缝的用户体验。

在本章中,我们将讨论创建出色的认证用户体验和实现坚实基础体验的挑战。我们将继续采用以路由器为起点的方法来设计单页应用(SPAs),通过实现 LemonMart 的认证体验。在第四章创建以路由器为起点的业务应用中,我们定义了用户角色,完成了所有主要路由的构建,并完成了 LemonMart 的粗略导航体验。这意味着我们已经为实施基于角色的条件导航体验做好了充分准备,该体验能够捕捉无缝认证体验的细微差别。我们将使用 Google Firebase 认证服务作为认证提供者来补充这一点,您可以在实际应用中利用它。

在本章中,您将了解以下主题:

  • 动态 UI 组件和导航

  • 使用守卫实现基于角色的路由

  • 一个 Firebase 认证配方

  • 使用工厂提供服务

技术要求

书中示例代码的最新版本可在以下链接存储库中找到。该存储库包含代码的最终和完成状态。您可以在本章结束时通过查找projects文件夹下的章节结束代码快照来验证您的进度。

对于第六章

  1. 克隆github.com/duluca/lemon-mart存储库。

  2. 在根目录中执行npm install以安装依赖项。

  3. 您将继续从上一章的stage8构建:

    projects/stage7 
    
  4. 项目的最终状态反映在:

    projects/stage8 
    
  5. 将舞台名称添加到任何ng命令中,使其仅在该阶段生效:

    npx ng build stage8 
    

注意,存储库根目录中的dist/stage8文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能不同。这些项目周围的生态系统一直在不断发展。由于 Angular CLI 生成新代码的方式的变化、错误修复、库的新版本以及多种技术的并行实现,存在许多难以预料的差异。如果您发现错误或有疑问,请在 GitHub 上创建问题或提交拉取请求。

在内存中的认证提供者就位后,让我们利用我们为动态 UI 组件和基于角色的条件导航系统编写的所有支持代码。

动态 UI 组件和导航

AuthService 提供异步的认证状态和用户信息,包括用户的姓名和角色。我们可以使用所有这些信息来创建一个友好且个性化的用户体验。在下一节中,我们将实现 LoginComponent,以便用户可以输入他们的用户名和密码信息并尝试登录。

实现登录组件

LoginComponent 利用我们创建的 AuthService 并使用响应式表单实现验证错误。

记住,在 app.config.ts 中,我们使用 InMemoryAuthService 类提供了 AuthService。因此,在运行时,当 AuthService 注入到 LoginComponent 中时,将使用内存服务。

LoginComponent 应该设计成可以独立于任何其他组件渲染,因为在路由事件期间,如果我们发现用户没有正确认证或授权,我们将导航他们到这个组件。我们可以捕获这个原始 URL 作为 redirectUrl,这样一旦用户成功登录,我们就可以将他们导航回该 URL。

让我们开始:

  1. 在应用程序的根目录下创建一个名为 login 的新组件,并使用内联样式。

  2. 让我们先实现到 LoginComponent 的路由:

    **src/app/app-routing.modules.ts**
    ...
      { path: 'login', component: LoginComponent },
      { path: 'login/:redirectUrl', component: LoginComponent },
    ... 
    

    记住,'**' 路径必须是最后一个定义的。

  3. 使用与我们在 HomeComponent 中实现的类似 login 逻辑,使用一些样式实现 LoginComponent

    不要忘记为即将进行的步骤将所需的依赖模块导入到你的 Angular 应用程序中。这有意留作练习,让你找到并导入缺失的模块。

    **src/app/login/login.****component****.****ts**
    …
    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; }
        `, 
      standalone: true,
      imports: [
        FlexModule,
        MatCardModule,
        ReactiveFormsModule,
        MatIconModule,
        MatFormFieldModule,
        MatInputModule,
        FieldErrorDirective,
        MatButtonModule,
        MatExpansionModule,
        MatGridListModule,
      ],
    })
    export class LoginComponent implements OnInit { 
      private readonly formBuilder = inject(FormBuilder)
      private readonly authService = inject(AuthService)
      private readonly router = inject(Router)
      private readonly route = inject(ActivatedRoute)
      loginForm: FormGroup
      loginError = ''  
      get redirectUrl() {
        return this.route.snapshot
                   .queryParamMap.get('redirectUrl') || ''
      }
    
      ngOnInit() {
        this.authService.logout()
        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
          )
          .pipe(catchError(err => (this.loginError = err)))
        combineLatest([
          this.authService.authStatus$,
          this.authService.currentUser$,
        ])
          .pipe(
            filter(
              ([authStatus, user]) =>
                authStatus.isAuthenticated && user?._id !== ''
            ),
            first(),
            tap(([authStatus, user]) => {
              this.router.navigate([this.redirectUrl || '/manager'])
            })
          )
          .subscribe()
      } 
    } 
    

    我们使用 first 操作符来管理订阅。我们确保在调用 ngOnInit 时我们已注销。我们以标准方式构建响应式表单。最后,login 方法调用 this.authService.login 来启动登录过程。

    我们使用 combineLatest 同时监听 authStatus$currentUser$ 数据流。每当每个流中发生更改时,我们的管道都会执行。我们过滤掉不成功的登录尝试。作为成功登录尝试的结果,我们利用路由将认证用户导航到其个人资料。在服务器通过服务发送错误的情况下,我们将该错误分配给 loginError

  4. 这里是一个登录表单的实现,用于捕获和验证用户的 emailpassword,并在出现任何服务器错误时显示它们:

    不要忘记在 app.modules.ts 中导入 ReactiveFormsModule

    **src/app/login/login.component.html**
    <div fxLayout="row" fxLayoutAlign="center">
      <mat-card appearance="outlined" fxFlex="400px">
        <mat-card-header>
          <mat-card-title>
            <div class="mat-headline-5">Hello, Limoncu!</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"
                  #email />
                <mat-error [input]="email" [group]="loginForm"
                                    appFieldError="invalid">
                </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"
                  #password />
                <mat-hint>Minimum 8 characters</mat-hint>
                <mat-error
                  [input]="password"
                  [group]="loginForm"
                  [appFieldError]=
                    "['required', 'minlength', 'maxlength']">
                </mat-error>
              </mat-form-field>
            </div>
            <div fxLayout="row" class="margin-top">
              @if (loginError) {
                <div 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,除非你为更多错误创建更多空间,所以请确保将错误条件按正确顺序放置。

    完成实现 LoginComponent 后,你可以更新主屏幕以有条件地显示或隐藏我们创建的新组件。

  5. 更新HomeComponent以清理我们之前添加的代码,以便在用户访问应用程序的主页时显示LoginComponent

    **src/app/home/home.****component****.****ts**
      ...
      template: `
        @if (displayLogin) {
          <app-login></app-login>
        } @else {
          <span class="mat-display-3">
            You get a lemon, you get a lemon, you get a lemon...
          </span>
        }
      `,
    }) 
    export class HomeComponent {
      displayLogin = true
      constructor() {
      }
    } 
    

您的应用程序应类似于以下截图:

图 6.1:LemonMart 登录界面

根据用户的认证状态,我们还需要做一些工作来实现和显示/隐藏sidenav菜单、个人资料和注销图标。

条件导航

条件导航对于创建无烦恼的用户体验是必要的。通过选择性地显示用户可以访问的元素并隐藏他们无法访问的元素,我们使用户能够自信地导航应用程序。

让我们从隐藏用户登录应用程序后的LoginComponent开始:

  1. HomeComponent中,将AuthService注入构造函数作为public变量:

    **src/app/home/home.****component****.****simple****.****ts**
    ...
    import { AuthService } from '../auth/auth.service'
    ...
    export class HomeComponent { 
      constructor(public authService: AuthService) {}
    } 
    
  2. 删除局部变量displayLogin,因为我们可以直接在模板中使用async管道访问认证状态。

  3. 使用控制流语法和async管道,实现一个新的模板,如下所示:

    **src/app/home/home.****component****.****ts**
    ...
      template: `
        **@if ((authService.authStatus$ | async)?.isAuthenticated) {**
          <div>      
            <div class="mat-display-4">
              This is LemonMart! The place where
            </div>
            <div class="mat-display-4">
              You get a lemon, you get a lemon, you get a lemon...
            </div>
            <div class="mat-display-4">
              Everybody gets a lemon.
            </div>
          </div>
        **} @else {**
          <app-login></app-login>
        **}**
      `,
       standalone: true,
       imports: [LoginComponent, AsyncPipe], 
    

    使用async管道可以避免像Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked这样的错误。每当您看到这个错误时,请停止使用局部变量,而应使用async管道。这是反应式操作的正确做法!

  4. AppComponent中,我们将通过注入AuthService作为public变量来遵循类似的模式:

    **src/app/app.****component****.****ts**
    import { Component, OnInit } from '@angular/core'
    import { AuthService } from './auth/auth.service'
    ...
    export class AppComponent implements OnInit { 
      constructor(..., **public****authService****:** **AuthService**) {
      }
      ngOnInit(): void {}
      ...
    } 
    
  5. 在模板中更新mat-toolbar,以便我们使用async管道监控authStatus$currentUser$

    **@if** **({**
      **status****: authService.****authStatus$** **|** **async****,**
      **user****: authService.****currentUser$** **|** **async**
    **};** **as** **auth;) {** 
       <mat-toolbar ... 
    
  6. 使用@if来隐藏所有针对已登录用户的按钮:

    **src/app/app.****component****.****ts**
    @if (auth?.status?.isAuthenticated) {
      <button ... > 
    

    现在,当用户注销时,您的工具栏应该看起来很干净,没有按钮,如图所示:

    图 6.2:用户登录前的 LemonMart 工具栏

  7. 如果用户有图片,我们还可以在profile按钮中替换通用的account_circle图标:

    **src/app/app.****component****.****ts**
    **import** **{** **NgOptimizedImage** **}** **from****'****@angular/common'**
    styles: `
      .image-cropper {
        border-radius: 50%;
      }
    `,
    template: `
      ...
      @if (auth?.status?.isAuthenticated) {
        <button mat-mini-fab routerLink="/user/profile" 
         matTooltip="Profile" aria-label="User Profile">
        @if (auth?.user?.picture) {
          <img alt="Profile picture" class="image-cropper" 
               **[ngSrc]="auth?.user?.picture ?? ''"** 
               width="40px" height="40px" fill />
        }
        @if (!auth?.user?.picture) {
          <mat-icon>account_circle</mat-icon>
        }
      </button>
    }
    ...
    `
    standalone: true,
      imports: [
        FlexModule,
        RouterLink,
        NavigationMenuComponent,
        RouterOutlet,
        AsyncPipe,
        MatIconModule,
        MatToolbarModule,
        MatButtonModule,
        MatSidenavModule,
        NgOptimizedImage,
      ], 
    

注意在img标签中使用ngSrc属性,这会激活NgOptimizedImage指令。此指令使得采用性能最佳实践来加载图片变得容易。它具有丰富的功能,可以优先或延迟加载某些图片,以帮助在快速首次内容绘制FCP)场景中,允许使用 CDN,并强制使用widthheight属性以防止在图片加载时可能发生的布局偏移。

angular.dev/guide/image-optimization了解更多关于NgOptimizedImage的信息。

我们现在有一个高度功能化的工具栏,它能够响应应用程序的认证状态,并且还可以显示属于已登录用户的信息。

表单的常见验证

在我们继续之前,我们需要重构 LoginComponent 的验证。随着我们在 第八章 中实现更多表单,食谱 - 可重用性、表单和缓存,你会发现反复在模板或响应式表单中键入表单验证非常快就会变得繁琐。响应式表单的魅力之一是它们由代码驱动,因此我们可以轻松地将验证提取到一个共享类中,并进行单元测试和重用,如下所示:

  1. common 文件夹下创建一个名为 validations.ts 的文件。

  2. 实现电子邮件和密码验证:

    **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),
    ] 
    

    根据您的密码验证需求,您可以使用 Validations.pattern() 函数配合 RegEx 模式来强制执行密码复杂度规则,或者利用 OWASP 的 npmowasp-password-strength-test 来启用密码短语,以及设置更灵活的密码要求。请参阅 进一步阅读 部分的 OWASP 认证通用指南链接。

  3. 使用新的验证更新 LoginComponent

    **src/app/login/login.****component****.****ts**
    import {
      EmailValidation, PasswordValidation
    } from '../common/validations'
    ...
    this.loginForm = this.formBuilder.group({
      email: ['', EmailValidation],
      password: ['', PasswordValidation],
    }) 
    

接下来,让我们将一些常见的 UI 行为封装到一个 Angular 服务中。

使用环境提供者的 UI 服务

当我们开始处理复杂的流程,如认证流程时,能够以编程方式向用户显示 toast 通知非常重要。在其他情况下,我们可能希望在执行具有更侵入性弹出通知的破坏性操作之前请求确认。

无论您使用什么组件库,重复编写相同的样板代码来显示快速通知都会变得很繁琐。UI 服务可以整洁地封装一个默认实现,该实现可以自定义。

在 UI 服务中,我们将实现 showToastshowDialog 函数,这些函数可以触发通知或提示用户做出决定,使我们能够在实现业务逻辑的代码中使用它们。

让我们开始吧:

  1. common 下创建一个名为 ui 的新服务。

  2. 使用 MatSnackBar 实现一个 showToast 函数:

    查阅 material.angular.io 上的 MatSnackBar 文档。

    由于此服务可以被任何服务、组件或功能模块使用,我们无法在模块上下文中声明此服务。由于我们的项目是一个独立项目,我们因此需要实现一个 环境提供者,以便我们可以在 app.config.ts 中定义的应用程序上下文中提供该服务。

    **src/app/common/ui.****service****.****ts**
    @Injectable({
      providedIn: 'root',
    })
    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,
          }
        )
      }
    } 
    

    对于使用 MatDialogshowDialog 函数,我们必须实现一个基本的 dialog 组件。

    查阅 material.angular.io 上的 MatDialog 文档。

  3. common 文件夹下添加一个名为 simpleDialog 的新组件,包含内联模板和样式,跳过测试,并保持扁平的文件夹结构:

    **app/common/simple-dialog.****component****.****ts**
    import { Component, Inject } from '@angular/core'
    import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'
    @Component({
      // prettier-ignore
      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>
          @if (data.cancelText) {
            <button mat-button mat-dialog-close>
              {{ data.cancelText }}
            </button>
          }
          <button mat-button mat-button-raised color="primary"
            [mat-dialog-close]="true" cdkFocusInitial>
            {{ data.okText }}
          </button>
        </mat-dialog-actions>
      `,
      standalone: true,
      imports: [MatDialogModule, MatButtonModule],
    })
    export class SimpleDialogComponent {
      constructor(
        public dialogRef: MatDialogRef<SimpleDialogComponent, boolean>,
        @Inject(MAT_DIALOG_DATA)
        public data: {
          title: string;
          content: string;
          okText: string;
          cancelText: string
        }
      ) {}
    } 
    

    SimpleDialogComponent 不应具有 selector: 'app-simple-dialog' 这样的应用程序选择器,因为我们只计划与 UiService 一起使用它。如果自动生成,请从您的组件中删除此属性。

  4. 现在,使用MatDialog实现一个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, content, okText, cancelText },
        }
      )
      return dialogRef.afterClosed()
    } 
    

    ShowDialog返回一个Observable<boolean>,因此你可以根据用户所做的选择实现后续操作。点击确定将返回true,点击取消将返回false

    SimpleDialogComponent中,使用@Inject,我们可以使用showDialog发送的所有变量来自定义对话框的内容。

  5. UiService的底部添加一个名为provideUiService的环境提供者:

    **app/common/ui.****service****.****ts**
    import { importProvidersFrom, makeEnvironmentProviders } from '@angular/core'
    export function provideUiService() {
      return makeEnvironmentProviders([
        importProvidersFrom(MatDialogModule, MatSnackBarModule),
      ])
    } 
    

    makeEnvironmentProviders允许我们将Service的依赖项封装在一个对象中。这样,我们不会将这些依赖项暴露给使用服务的组件。这有助于我们强制执行解耦架构。

  6. app.config.ts中,将provideUiService()添加到providers数组中:

    **src/app/app.****config****.****ts**
    export const appConfig: ApplicationConfig = {
      providers: [
        ...
        provideUiService()
      ]
    } 
    
  7. 更新LoginComponent中的login()函数,在登录后显示一个吐司消息:

    **src/app/login/login.****component****.****ts**
    import { UiService } from '../common/ui.service'
    ...
      **private****readonly** **uiService =** **inject****(****UiService****)** 
      ...
      async login(submittedForm: FormGroup) {
        ...
        tap(([authStatus, user]) => {
          **this****.****uiService****.****showToast****(**
            **`Welcome** **${user.fullName}****! Role:** **${user.role}****`**
          )
          ...
        })
     ... 
    

    现在,当用户登录后,将显示一个吐司消息,如图所示:

    图 6.3:Material snackBar

    snackBar将根据浏览器的大小占据整个屏幕宽度或部分宽度。

  8. 尝试显示一个对话框代替:

    **src/app/login/login.****component****.****ts**
    this.uiService.showDialog(
      `Welcome ${user.fullName}!`, `Role: ${user.role}`
    ) 
    

现在你已经验证了showToastshowDialog都工作正常,你更喜欢哪一个?

我在选择吐司消息或对话框时的经验法则是,除非用户即将采取不可逆的操作,否则你应该选择吐司消息而不是对话框,这样就不会打断用户的操作流程。

接下来,让我们实现一个全局侧导航体验,作为我们已有的基于工具栏导航的替代方案,以便用户可以轻松地在模块之间切换。

侧导航

为了提升用户体验,启用以移动端优先的工作流程并提供直观的导航机制,使用户能够快速访问所需的功能至关重要。侧导航栏(SideNav)对移动端和桌面用户都同样适用。在移动屏幕上,可以通过三横线(汉堡)菜单激活,在大屏幕上可以锁定打开。为了进一步优化体验,我们应该只显示用户有权查看的链接。我们可以通过根据用户的当前角色利用AuthenticationService来实现这一点。我们将按照以下方式实现侧导航模拟图:

图 6.4:侧导航模拟图

让我们将侧导航的代码作为一个单独的组件来实现,这样更容易维护:

  1. 在应用程序的根目录中创建一个带有内联模板和样式的NavigationMenu组件。

    侧导航在用户登录后技术上不是必需的。然而,为了能够从工具栏启动侧导航菜单,我们需要能够从 AppComponent 触发它。由于这个组件将是简单的,我们将急切地加载它。为了实现懒加载,Angular 确实有一个动态组件加载模式,但这将产生较高的实现开销,只有在节省了数百万字节的情况下才有意义。

    SideNav 将从工具栏触发,并附带一个 <mat-sidenav-container> 父容器,该容器本身托管 SideNav 以及应用程序的内容。因此,我们必须通过将 <router-outlet> 放置在 <mat-sidenav-content> 内部来渲染所有应用程序内容。

  2. AppComponent 中,定义一些样式以确保网络应用程序将扩展以填充整个页面,并在桌面和移动场景中保持适当的可滚动性:

    **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;
        }
        .image-cropper {
          border-radius: 50%;
        }
      `, 
    
  3. AppComponent 中注入 Angular Flex Layout 的 MediaObserver 服务。同时实现 OnInit,注入 DestroyRef,并添加一个名为 opened 的布尔属性:

    **src/app/app.****component****.****ts**
    import { MediaObserver } from '@ngbracket/ngx-layout '
    export class AppComponent implements OnInit {
      **private** **destroyRef =** **inject****(****DestroyRef****)**
      **opened****:** **boolean**
      constructor(
        ...
        **public****media****:** **MediaObserver**
      ) {
      ...
      }
      ngOnInit(): void {
        throw new Error('Method not implemented.')
      }
    } 
    

    为了自动确定侧导航的打开/关闭状态,我们需要监控媒体观察器和认证状态。当用户登录时,我们希望显示侧导航,当用户注销时隐藏它。我们可以通过将 opened 赋值为 authStatus$.isAuthenticated 的值来实现这一点。然而,如果我们只考虑 isAuthenticated,并且用户在移动设备上,我们将创建一个不太理想的用户体验。通过监控媒体观察器的 mediaValue,我们可以检查屏幕尺寸是否设置为超小或 xs;如果是这样,我们可以保持侧导航关闭。

  4. 更新 ngOnInit 以实现动态侧导航的打开/关闭逻辑:

    **src/app/app.****component****.****ts**
      ngOnInit() {
        combineLatest([
          this.media.asObservable(),
          this.authService.authStatus$,
        ])
          .pipe(
            tap(([mediaValue, authStatus]) => {
              if (!authStatus?.isAuthenticated) {
                this.opened = false
              } else {
                if (mediaValue[0].mqAlias === 'xs') {
                  this.opened = false
                } else {
                  this.opened = true
                }
              }
            }),
            takeUntilDestroyed(this.destroyRef)
          )
          .subscribe()
      } 
    

    通过监控媒体和 authStatus$ 流,我们可以考虑未经认证的场景,即使有足够的屏幕空间,侧导航也不应该打开。我们还使用 takeUntilDestroyed 以便清理我们的资源。

  5. 更新模板,以实现响应式的 SideNav,在移动场景中滑过内容,在桌面场景中将内容推到一边:

    **src/app/app.****component****.****ts**
    ...
    // prettier-ignore
    template: `
      **<div class="app-container">**
          @if (
            {
              status: authService.authStatus$ | async,
              user: authService.currentUser$ | async
            };
            as auth;
          ) {
            <mat-toolbar color="primary" fxLayoutGap="8px" 
             **class="app-toolbar"** 
             **[class.app-is-mobile]="media.isActive('xs')"**
              >
              @if (auth?.status?.isAuthenticated) {
                <button mat-icon-button 
                  **(click)="sidenav.toggle()">**
                  <mat-icon>menu</mat-icon>
                </button>
              }
        ...
      </mat-toolbar>
      <mat-sidenav-container class="app-sidenav-container">
        <mat-sidenav #sidenav
          [mode]="media.isActive('xs') ? 'over' : 'side'"
          [fixedInViewport]="media.isActive('xs')"
          fixedTopGap="56" [(opened)]="opened"
        >
          <app-navigation-menu></app-navigation-menu>
        </mat-sidenav>
        <mat-sidenav-content>
          <router-outlet></router-outlet>
        </mat-sidenav-content>
      </mat-sidenav-container>
      </div>
    `, 
    

    上述模板利用了 @ngbracket/ngx-layout 中的媒体观察器,这是已弃用的 Angular Flex Layout 库的社区克隆版。我们之前注入 ngx-layout 是为了实现响应式布局。

    您可以在模板上方使用 // prettier-ignore 指令来防止 Prettier 将您的模板拆分成太多行,这在某些条件下(如本例)可能会损害可读性。

    我们将在 NavigationMenuComponent 中实现导航链接。随着时间的推移,我们应用程序中的链接数量可能会增加,并受到各种基于角色的业务规则的影响。因此,如果我们将这些链接实现到 app.component.ts 中,我们可能会使该文件变得过大。此外,我们不想让 app.component.ts 频繁更改,因为那里的更改可能会影响整个应用程序。将链接实现为单独的组件是一种良好的实践。

  6. NavigationMenuComponent 中实现导航链接:

    **src/app/navigation-menu/navigation-menu.****component****.****ts**
    ...
      styles: `
          .active-link {
            font-weight: bold;
            border-left: 3px solid green;
          }
          .mat-mdc-subheader {        font-weight: bold;      }
      `,
      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>
      `,
      standalone: true,
      imports: [MatListModule, RouterLinkActive, RouterLink],
    ... 
    

    <mat-nav-list> 在功能上等同于 <mat-list>,因此你可以使用 MatList 的文档进行布局。在此观察经理库存职员的子标题:

    图 6.5:桌面上的经理仪表板显示收据查找

    routerLinkActive="active-link" 突出了选中的收据路由,如图中所示。

    Angular Router 跟踪应用程序中的导航状态。根据哪个链接是活动的,它会自动分配适当的 CSS,以便将其突出显示为活动链接。

    你可以在 angular.dev/guide/routing/router-reference 上了解更多关于路由的信息。

    此外,你可以看到在移动设备上的外观和行为差异如下:

    图 6.6:移动设备上的经理仪表板显示收据查找

接下来,让我们实现基于角色的路由。

基于角色的路由使用守卫

这是您应用程序最基本且最重要的部分。通过懒加载,我们确保只加载最基本数量的资源,以便用户能够登录。

一旦用户登录,他们应该根据用户角色被路由到适当的登录屏幕,这样他们就不会猜测如何使用应用程序。例如,收银员只需要访问**销售点(POS)**屏幕,以便他们可以结账客户。在这种情况下,收银员可以自动被路由到该屏幕。

下面的 POS 屏幕是一个原型:

图 6.7:POS 屏幕原型

通过更新 LoginComponent 确保用户登录后能够被路由到适当的页面。

在名为 homeRoutePerRole 的函数中更新每个角色的路由 login 逻辑:

**app/src/login/login.****component****.****ts**
async login(submittedForm: FormGroup) {
  ...
    this.router.navigate([
      this.redirectUrl ||
      **this****.****homeRoutePerRole****(user.****role****as****Role****)**
    **])**
  **...**
**}**
**private****homeRoutePerRole****(****role****:** **Role****) {**
  **switch** **(role) {**
    **case****Role****.****Cashier****:**
      **return****'/pos'**
    **case****Role****.****Clerk****:**
      **return****'/inventory'**
    **case****Role****.****Manager****:**
      **return****'/manager'**
    **default****:**
      **return****'/user/profile'**
  **}**
**}** 

同样,职员和经理将被路由到他们的登录屏幕以访问他们完成任务所需的功能,如前所述。由于我们已经实现了默认的管理员角色,相应的登录体验将自动启动。

在下一节中,你将了解路由守卫,它有助于检查用户身份验证,甚至可以在表单渲染之前加载数据。这在防止用户意外访问他们不应访问的路由以及阻止有意尝试突破这些限制方面至关重要。

路由守卫

路由守卫使逻辑的进一步解耦和重用成为可能,并提供了对组件生命周期的更多控制。

这里是您最可能使用的四个主要守卫:

  • canActivatecanActivateChild:用于检查路由的认证访问

  • canDeactivate:用于在离开路由之前请求权限

  • Resolve:允许从路由参数中预取数据

  • CanLoad:允许在加载功能模块资源之前执行自定义逻辑

请参阅以下部分以了解如何利用canActivatecanLoadResolve守卫将在第八章食谱 – 可重用性、表单和缓存中介绍。

身份验证守卫

身份验证守卫通过允许或禁止在模块加载之前或在进行任何不适当的数据请求之前意外导航到功能模块或组件,从而提供良好的用户体验。例如,当管理员登录时,他们将被自动路由到/manager/home路径。浏览器将缓存此 URL,因此文书人员意外导航到相同的 URL 是完全可能的。Angular 不知道特定路由是否对用户可访问。如果没有authGuard,它将愉快地渲染管理员的首页并触发将失败的服务器请求。

不论您的前端实现多么健壮,您实现的每个 REST 或 GraphQL API 都应该在服务器端使用基于角色的访问控制(RBAC)进行适当的保护。

让我们更新路由器,以便在没有经过身份验证的用户的情况下无法激活ProfileComponent,并且ManagerModule只有在管理员使用authGuard登录时才会加载:

  1. 实现一个功能性的AuthGuard

    **src/app/auth/auth.****guard****.****ts**
    export const authGuard = (route?: ActivatedRouteSnapshot) => {
      const authService = inject(AuthService)
      const router = inject(Router)
      const uiService = inject(UiService)
      return checkLogin(authService, router, uiService, route)
    } 
    

    注意,所有依赖项都是通过注入函数内联注入的,这允许在@Injectable类的构造函数之外进行依赖注入,在这种情况下是一个函数。

    function checkLogin(
      authService: AuthService,
      router: Router,
      uiService: UiService,
      route?: ActivatedRouteSnapshot
    ): Observable<boolean> {
      return authService.authStatus$.pipe(
        map((authStatus) => {
          const roleMatch = checkRoleMatch(authStatus.userRole, route)
          const allowLogin = authStatus.isAuthenticated && roleMatch
          if (!allowLogin) {
            showAlert(uiService, authStatus.isAuthenticated, roleMatch)
            router.navigate(['login'], {
              queryParams: {
                redirectUrl: router?.getCurrentNavigation()?
                             .initialUrl.toString(),
              },
            })
          }
          return allowLogin
        }),
        take(1) // the observable must complete for the guard to work
      )
    }
    function checkRoleMatch(role: Role, route?: ActivatedRouteSnapshot) {
      if (!route?.data?.['expectedRole']) {
        return true
      }
      return role === route.data['expectedRole']
    }
    function showAlert(
      uiService: UiService,
      isAuth: boolean,
      roleMatch: boolean
    ) {
      if (!isAuth) {
        uiService.showToast('You must login to continue')
      }
      if (!roleMatch) {
        uiService.showToast(
          'You do not have the permissions to view this resource'
        )
      }
    } 
    
  2. 使用canLoad守卫防止加载懒加载的模块,例如manager模块:

    **src/app/app.****routes****.****ts**
    **import** **{ authGuard }** **from****'./auth/auth.guard'**
    ...
    {
      path: 'manager',
      loadChildren: () => import('./manager/manager.module')
        .then((m) => m.ManagerModule), 
      **canLoad****: [authGuard],**
      **data****: {** **expectedRole****:** **Role****.****Manager** **},**
    },
    ... 
    

    在这种情况下,当ManagerModule加载时,authGuard将在canLoad事件期间被调用,checkLogin函数将验证用户的身份验证状态。如果守卫返回false,则模块将不会加载。

    我们可以更进一步,在路由定义中提供额外的元数据,如expectedRole,它将通过canActivate事件传递给checkLogin函数。如果用户已通过身份验证,但他们的角色不匹配Role.Manager,则authGuard将再次返回false,模块将不会加载。

  3. 使用canActivate守卫防止激活单个组件,例如用户的profile

    **src/app/user/user-routing.****module****.****ts**
    ...
    { 
      path: 'profile', component: ProfileComponent, 
      **canActivate****: [authGuard]** 
    },
    ... 
    

    user-routing.module.ts的情况下,authGuardcanActivate事件期间被调用,checkLogin函数控制此路由可以导航的位置。由于用户正在查看自己的个人资料,因此在这里不需要检查用户的角色。

  4. 使用具有 expectedRole 属性的 canActivatecanActivateChild 来防止其他用户激活组件,例如 ManagerHomeComponent

    **src/app/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,如果角色不匹配 Role.Manager,则 authGuard 将返回 false,从而阻止导航。

接下来,我们将回顾一些实现单元测试以隔离依赖的技术。

认证服务模拟和通用测试提供者

我们需要使用 common.testing.ts 中的 commonTestingProviders 函数提供 AuthServiceUiService 等服务的模拟版本,使用与在 第四章 中提到的 commonTestingModules 相似的模式。这样,我们就不会反复模拟相同的对象。

让我们使用来自 angular-unit-test-helperautoSpyObj 函数创建间谍对象,并回顾一些不那么明显的更改,以使我们的测试通过:

  1. common.testing.ts 中更新 commonTestingProviders

    **src/app/common/common.****testing****.****ts**
    import { autoSpyObj } from 'angular-unit-test-helper'
    export const commonTestingProviders: any[] = [
      { provide: AuthService, useValue: autoSpyObj(AuthService) },
      { provide: UiService, useValue: autoSpyObj(UiService) }, 
    ] 
    
  2. 观察在 app.component.spec.ts 中为 MediaObserver 提供的测试双例,并将其更新为使用 commonTestingModules

    **src/app/app.****component****.****spec****.****ts**
    ...
      TestBed.configureTestingModule({
        **imports****: [...commonTestingModules],**
        providers: [
          **{** **provide****:** **MediaObserver****,** **useClass****:** **MediaObserverFake** **},**
    ... 
    

    注意我们如何使用扩展语法 ... 在另一个数组中展开 commonTestingModules。这样,当你需要向数组中添加更多项时,只需在旁边添加一个 common 和另一个元素就非常方便。

    不要将扩展语法 与本书中用于表示代码片段中周围代码存在的省略号 混淆。

  3. 更新 LoginComponentspec 文件以利用 commonTestingModulescommonTestingProviders

    **src/app/login/login.****component****.****spec****.****ts**
    ...
      TestBed.configureTestingModule({
        **imports****: [... commonTestingModules],**
        **providers****: [... commonTestingProviders],**
        declarations: [LoginComponent],
      }).compileComponents() 
    
  4. 然后,将此技术应用于所有依赖于 AuthServiceUiServicespec 文件。

  5. 值得注意的是,对于服务,例如在 auth.service.spec.ts 中,你想使用测试双例。由于 AuthService 是被测试的类,请确保它按以下方式配置:

    **src/app/auth/auth.****service****.****spec****.****ts**
    ...
    TestBed.configureTestingModule({
      **imports****: [****HttpClientTestingModule****],**
      **providers****: [****AuthService****,** 
      **{** **provide****:** **UiService****,** **useValue****:** **autoSpyObj****(****UiService****) }],**
    }) 
    
  6. 使用类似考虑更新 ui.service.spec.ts

记住,直到所有测试通过,不要继续前进!

Firebase 认证配方

我们可以利用当前的认证设置并将其与真实的认证服务集成。对于本节,你需要一个免费的 Google 和 Firebase 账户。Firebase 是 Google 的综合移动开发平台:firebase.google.com。你可以创建一个免费账户来托管你的应用程序并利用 Firebase 认证系统。

Firebase 控制台,位于 console.firebase.google.com,允许你管理用户并发送密码重置电子邮件,而无需为你的应用程序实现后端。稍后,你可以利用 Firebase 函数以无服务器的方式实现 API。

首先,使用 Firebase 控制台将你的项目添加到 Firebase:

蓝色屏幕的截图 自动生成的描述

图 6.8:Firebase 控制台

  1. 点击添加项目

  2. 提供您的项目名称。

  3. 为您的项目启用 Google Analytics。

在尝试此操作之前创建一个 Google Analytics 账户可能会有所帮助,但它仍然应该可以工作。一旦您的项目创建完成,您应该看到您的项目仪表板:

计算机的截图 自动生成的描述

图 6.9:Firebase 项目概览

在左侧,标记为1的地方,您可以看到可以添加到项目中的工具和服务菜单。在顶部,标记为2的地方,您可以快速在项目之间切换。在这样做之前,您需要向项目中添加一个应用程序。

创建 Firebase 应用程序

您的项目可以包含您应用程序的多个分发版本,如 Web、iOS 和 Android 版本。在本章中,我们只对添加 Web 应用程序感兴趣。

让我们开始吧:

  1. 在您的项目仪表板上,点击 Web 应用程序按钮以添加应用程序,这在图 6.9中标记为3

  2. 提供一个应用程序昵称。

  3. 选择设置Firebase 托管的选项。

  4. 通过点击注册应用按钮继续。

  5. 跳过添加 Firebase SDK部分。

  6. 按照说明安装 Firebase CLI:

    $ npm install -g firebase-tools 
    
  7. 登录:

    $ firebase login 
    

确保您的当前目录是您的项目根文件夹。

  1. 初始化您的项目:

    $ firebase init 
    
  2. 选择托管选项。不用担心,您稍后可以添加更多功能。

  3. 选择您创建的项目作为默认项目,即lemon-mart-007

  4. 回答“检测到当前目录中存在现有的 Angular 代码库,我们应该使用这个吗?”时说“是”。

    这将创建两个新的文件:firebase.json.firebaserc

  5. 为生产构建您的项目:

    $ npx ng build --prod 
    

    或者

    $ npm run build:prod 
    
  6. 现在,您可以通过执行以下命令来部署您的 Angular 应用程序:

    $ firebase deploy 
    

您的网站应该在类似lemon-mart-007.firebaseapp.com的 URL 上可用,如终端中所示。

.firebase文件夹添加到.gitignore中,这样您就不会提交您的缓存文件。其他两个文件,firebase.json.firebaserc,可以安全提交。

可选地,使用 Firebase 控制台将您拥有的自定义域名连接到账户。

配置 Firebase 身份验证

现在,让我们配置身份验证。

在 Firebase 控制台中:

  1. 展开构建菜单,并从侧边导航中选择身份验证登录页面的截图 自动生成的描述

    图 6.10:Firebase 身份验证页面

  2. 添加一个登录方法;选择电子邮件/密码作为提供者。

  3. 启用它。

  4. 不要启用电子邮件链接。

  5. 保存您的配置。

您现在可以看到用户管理控制台:

计算机的截图 自动生成的描述

图 6.11:Firebase 用户管理控制台

它的操作简单直观,所以我将把它作为练习留给你。

将 Firebase 认证提供者添加到 Angular 中

让我们从添加 Angular Fire 开始,这是 Angular 的官方 Firebase 库到我们的应用程序中:

$ npx ng add @angular/fire 

按照 Angular Fire 的快速入门指南完成设置库与您的 Angular 项目的配置,您可以在 GitHub 上的README文件中找到链接:github.com/angular/angularfire

  1. 确保 Firebase 模块按照文档在app.config.ts中提供。

  2. 将您的 Firebase config对象复制到所有的environment.ts文件中。

    注意,environment.ts中提供的任何信息都是公开信息。因此,当您将 Firebase API 密钥放在此文件中时,它将是公开可用的。有很小的可能性,其他开发者可能会滥用您的 API 密钥并增加您的账单。为了保护自己免受此类攻击,请查看 Paachu 的这篇博客文章:即使 API 密钥公开可用,如何保护您的 Firebase 项目,链接为medium.com/@impaachu/how-to-secure-your-firebase-project-even-when-your-api-key-is-publicly-available-a462a2a58843

  3. 创建一个新的FirebaseAuthService

    $ npx ng g s auth/firebaseAuth --lintFix 
    
  4. 重命名服务文件auth.firebase.service.ts

  5. 一定要删除{ providedIn: 'root' }

  6. 通过扩展抽象认证服务实现 Firebase 认证:

    **src/app/auth/auth.****firebase****.****service****.****ts**
    import { inject, Injectable } from '@angular/core'
    import {
      Auth as FireAuth,
      signInWithEmailAndPassword,
      signOut,
      User as FireUser,
    } from '@angular/fire/auth'
    import { Observable, of, Subject } from 'rxjs'
    import { IUser, User } from '../user/user/user'
    import { Role } from './auth.enum'
    import {
      AuthService,
      defaultAuthStatus,
      IAuthStatus,
      IServerAuthResponse,
    } from './auth.service'
    interface IJwtToken {
      email: string
      iat: number
      exp: number
      sub: string
    }
    @Injectable()
    export class FirebaseAuthService extends AuthService {
      private afAuth: FireAuth = inject(FireAuth)
      constructor() {
        super()
      }
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        const serverResponse$ = new Subject<IServerAuthResponse>()
        signInWithEmailAndPassword(this.afAuth, email, password).then(
          (res) => {
            const firebaseUser: FireUser | null = res.user
            firebaseUser?.getIdToken().then(
              (token) => serverResponse$.next({
                accessToken: token
              } as IServerAuthResponse),
              (err) => serverResponse$.error(err)
            )
          },
          (err) => serverResponse$.error(err)
        )
        return serverResponse$
      }
      protected transformJwtToken(token: IJwtToken): IAuthStatus {
        if (!token) {
          return defaultAuthStatus
        }
        return {
          isAuthenticated: token.email ? true : false,
          userId: token.sub,
          userRole: Role.None,
        }
      }
      protected getCurrentUser(): Observable<User> {
        return of(this.transformFirebaseUser(this.afAuth.currentUser))
      }
      private transformFirebaseUser(firebaseUser: FireUser | null): User {
        if (!firebaseUser) {
          return new User()
        }
        return User.Build({
          name: {
            first: firebaseUser?.displayName?.split(' ')[0] ||
                     'Firebase',
            last: firebaseUser?.displayName?.split(' ')[1] || 'User',
          },
          picture: firebaseUser.photoURL,
          email: firebaseUser.email,
          _id: firebaseUser.uid,
          role: Role.None,
        } as IUser)
      }
      override async logout() {
        if (this.afAuth) {
          await signOut(this.afAuth)
        }
        this.clearToken()
        this.authStatus$.next(defaultAuthStatus)
      }
    } 
    

    如您所见,我们只需实现我们已建立的认证代码与 Firebase 认证方法之间的差异。我们不需要复制任何代码,并且需要将 Firebase 的 user 对象转换为我们应用程序的内部用户对象。

    注意,在transformFirebaseUser中,我们设置role: Role.None,因为 Firebase 认证默认不实现用户角色的概念。为了使 Firebase 集成完全功能,您需要实现 Firebase 函数和 Firestore 数据库,以便您可以存储丰富的用户配置文件并在其上执行 CRUD 操作。在这种情况下,在认证后,您将再次调用以检索角色信息。在第七章与 REST 和 GraphQL API 一起工作中,我们介绍了如何在您的自定义 API 中实现这一点。

  7. 要使用 Firebase 认证而不是内存认证,更新app.config.ts中的AuthService提供者:

    **src/app/app.****config****.****ts**
      {
        provide: AuthService,
        useClass: **FirebaseAuthService**,
      }, 
    

    完成步骤后,从 Firebase 认证控制台添加新用户,您应该能够使用真实认证进行登录。

    总是确保在互联网上传输任何类型的个人身份信息PII)或敏感信息(如密码)时使用 HTTPS。否则,您的信息将被记录在第三方服务器上或被恶意行为者捕获。

  8. 再次提醒,在继续之前,务必更新您的单元测试:

    src/app/auth/auth.firebase.service.spec.ts
    import {
      HttpClientTestingModule
    } from '@angular/common/http/testing'
    import { inject, TestBed } from '@angular/core/testing'
    import { Auth as FireAuth } from '@angular/fire/auth'
    import { UiService } from '../common/ui.service'
    import { FirebaseAuthService } from './auth.firebase.service'
    const angularFireStub = {
      user: jasmine.createSpyObj('user', ['subscribe']),
      auth: jasmine.createSpyObj('auth',
                ['signInWithEmailAndPassword', 'signOut']),
    }
    describe('AuthService', () => {
      beforeEach(() => {
        TestBed.configureTestingModule({
          imports: [HttpClientTestingModule],
          providers: [
            FirebaseAuthService,
            UiService,
            { provide: FireAuth, useValue: angularFireStub },
          ],
        })
      })
      it('should be created', inject(
        [FirebaseAuthService],
        (service: FirebaseAuthService) => {
          expect(service).toBeTruthy()
        }
      ))
    }) 
    

停止!在部署真实认证方法之前,从你的项目中移除fake-jwt-sign包。

恭喜!你的应用程序已与 Firebase 集成!接下来,让我们来了解服务工厂,这可以帮助你动态切换抽象类的提供者。

使用工厂提供服务

你可以在加载时动态选择提供商,因此,你不需要更改代码来在认证方法之间切换,而是可以通过参数化环境变量来让不同类型的构建使用不同的认证方法。这在编写针对你的应用程序的自动化 UI 测试时特别有用,因为在实际环境中处理真实的认证可能很困难,甚至不可能。

首先,我们将在environment.ts中创建一个enum来帮助我们定义选项,然后我们将使用该enum在我们应用程序的引导过程中选择认证提供者。

让我们开始吧:

  1. 创建一个名为AuthMode的新enum

    **src/app/auth/auth.****enum****.****ts**
    export enum AuthMode {
      InMemory = 'In Memory',
      CustomServer = 'Custom Server',
      CustomGraphQL = 'Custom GraphQL',
      Firebase = 'Firebase',
    } 
    
  2. environment.ts中添加一个authMode属性:

    **src/environments/environment.****ts**
    ...
      authMode: AuthMode.**InMemory**,
    ...
    **src/environments/environment.****prod****.****ts**
    ...
      authMode: AuthMode.**Firebase**,
    ... 
    
  3. auth/auth.factory.ts的新文件中创建一个authFactory函数:

    **src/app/auth/auth.****factory****.****ts**
    import { environment } from '../../environments/environment'
    import { AuthMode } from './auth.enum'
    import { FirebaseAuthService } from './auth.firebase.service'
    import { InMemoryAuthService } from './auth.in-memory.service'
    export function authFactory() {
      switch (environment.authMode) {
        case AuthMode.InMemory:
          return new InMemoryAuthService()
        case AuthMode.Firebase:
          return new FirebaseAuthService()
        case AuthMode.CustomServer:
          throw new Error('Not yet implemented')
        case AuthMode.CustomGraphQL:
          throw new Error('Not yet implemented')
      }
    } 
    

    注意,工厂必须导入任何依赖的服务,如上所示。

  4. app.config.ts中的AuthService提供者更新为使用工厂:

    **src/app/app.****config****.****ts**
      providers: [
        {
          provide: AuthService,
          **useFactory****: authFactory**
        }, 
    

注意,你可以从app.config.ts中移除InMemoryAuthServiceFirebaseAuthService的导入。

使用此配置,每次你在开发配置中构建应用程序时,你将使用内存中的认证服务,而生产(prod)构建将使用 Firebase 认证服务。

摘要

你现在应该熟悉了如何创建高质量的认证体验。在本章中,我们设计了一个很好的条件导航体验,你可以通过将基本元素复制到你的项目中并实现自己的认证提供者来在你的应用程序中使用。我们创建了一个可重用的 UI 服务,这样你就可以方便地在应用程序的流程控制逻辑中显示警告。

我们介绍了路由守卫,以防止用户误入未经授权使用的屏幕,并重申了你的应用程序的真实安全性应该在服务器端实现的观点。你看到了如何使用工厂在不同的环境中动态提供不同的认证提供者。

最后,我们使用 Firebase 实现了真实的认证提供者。在第七章与 REST 和 GraphQL API 一起工作中,我们将回顾 LemonMart 服务器,这是一个使用 REST 和 GraphQL API 的最小 MEAN 堆栈的全栈实现。我们将通过学习如何实现自定义认证提供者和为 REST 和 GraphQL 端点实现 RBAC 来完成我们的认证之旅。

进一步阅读

问题

尽可能地回答以下问题,以确保你理解了本章的关键概念,而无需使用谷歌搜索。你知道你是否正确回答了所有问题吗?请访问angularforenterprise.com/self-assessment获取更多信息:

  1. RxJS 的combineLatestmerge操作符之间有什么区别?

  2. 在 Angular 路由守卫的上下文中,解释canActivatecanLoad之间的区别。

  3. 动态 UI 渲染如何提高基于角色的导航系统中的用户体验?

  4. 使用类似 Firebase Authentication 这样的服务进行 Web 应用程序的用户管理有哪些好处和潜在缺点?

  5. 描述一个场景,其中服务工厂在 Angular 应用程序中特别有用。

第七章:与 REST 和 GraphQL API 一起工作

第一章Angular 的架构和概念中,我向您介绍了网络应用存在的更广泛的架构,而在第三章构建企业应用中,我们讨论了可能影响您应用成功的一系列性能瓶颈。然而,您的网络应用的表现只能与您的全栈架构的表现相匹配。如果您正在使用不充分的 API 设计或缓慢的数据库,您将花费时间实施临时解决方案,而不是解决问题的根本原因。当我们摆脱最小化思维并开始修补漏洞时,我们就在构建一个可能崩溃或维护成本极高的脆弱塔楼的道路上。简而言之,全栈架构中做出的选择可以深刻影响网络应用的成功。您和您的团队根本无法忽视 API 的设计方式。通常,实现新功能或修复性能问题的正确方法是通过重新设计 API 端点。使用MongoDBExpressAngularNode.jsMEAN栈是一套围绕类似技术构建的流行技术集合,这些技术应该有助于网络开发者的采用。我对 MEAN 栈的看法是“最小化 MEAN”,它优先考虑易用性、健康和有效性,这些都是构建出色的DevEx的主要成分。

在过去两章中,我们为我们的应用设计并实现了一个基于角色的访问控制RBAC)机制。在第五章设计身份验证和授权中,我们深入探讨了安全考虑因素,介绍了 JWT 身份验证的工作原理,学习了如何使用 TypeScript 安全地处理数据,并利用面向对象编程(OOP)的设计,通过继承和抽象来构建一个可扩展的认证服务。在第六章实现基于角色的导航中,我们使用我们的认证服务设计了一个条件导航体验,并实现了针对自定义 API 和 Google Firebase 的认证提供者。

在本章中,我将向您介绍 LemonMart 服务器,该服务器实现了 JWT 身份验证、REST 和 GraphQL API。我们将使用这些 API 在 Angular 中实现两个自定义认证提供者。这将允许您对第八章食谱 – 可重用性、表单和缓存第九章食谱 – 主/详细、数据表和 NgRx中将要介绍的食谱进行身份验证调用。

本章涵盖了大量的内容。它旨在作为 GitHub 仓库的路线图。GitHub 仓库。我涵盖了架构、设计和实现的主要组件。我强调了一些重要的代码片段来解释解决方案是如何组合在一起的,但避免深入到实现细节。更重要的是,你需要理解我们为什么要实现各种组件,而不是对实现细节有深刻的掌握。对于本章,我建议你阅读并理解服务器代码,而不是试图自己重新创建它。

我们首先介绍全栈架构、LemonMart 服务器的 monorepo 设计以及如何使用 Docker Compose 运行具有 Web 应用、服务器和数据库的三层应用程序。然后,我们将回顾 REST 和 GraphQL API 的设计、实现和文档。对于 REST,我们将利用OpenAPI规范和SwaggerUI。对于 GraphQL,我们将利用GraphQL schemasApollo Studio。这两个 API 都将使用 Express.js 和 TypeScript 实现。然后,我们将介绍使用 DocumentTS 库实现 MongoDB 的对象文档映射器(ODM)以存储具有登录凭证的用户。最后,我们将实现基于令牌的身份验证功能来保护我们的 API 和 Angular 中的相应身份验证提供者。

在本章中,你将学习以下内容:

  • 全栈架构

  • 与 monorepos 一起工作

  • 设计 API

  • 使用 Express.js 实现 API

  • 使用 DocumentTS 的 MongoDB ODM

  • 实现 JWT 身份验证

  • 自定义服务器身份验证提供者

技术要求

书籍示例代码的最新版本可以在 GitHub 上的以下链接仓库中找到。链接仓库。该仓库包含代码的最终和完成状态。本章需要 Docker Desktop 和 Postman 应用程序。

确保你在开发环境中启动lemon-mart-server并且lemon-mart能够与之通信至关重要。请参考此处或 GitHub 上的README中的说明来启动你的服务器。

对于第七章的服务端实现:

  • 使用--recurse-submodules选项克隆lemon-mart-server仓库:

    git clone --recurse-submodules https://github.com/duluca/lemon-mart-server 
    
  • 在 VS Code 终端中,执行cd web-app; git checkout master以确保从github.com/duluca/lemon-mart克隆的子模块位于 master 分支。

    在后面的Git 子模块部分,你可以配置web-app文件夹以从你的lemon-mart服务器拉取。

  • root文件夹中执行npm install以安装依赖项。

    注意,在根目录中运行npm install命令会触发一个脚本,该脚本还会在serverweb-app文件夹下安装依赖项。

  • 在根目录中执行npm run init:env以配置.env文件中的环境变量。

    此命令将在根目录和 server 文件夹下创建两个 .env 文件,以包含您的私有配置信息。初始文件基于 example.env 文件生成。您可以在以后修改这些文件并设置自己的安全密钥。

  • 在根目录中执行 npm run build 以构建服务器和网页应用。

    注意,网页应用使用名为 --configuration=lemon-mart-server 的新配置构建,该配置使用 src/environments/environment.lemon-mart-server.ts

  • 执行 docker compose up --build 以运行服务器、网页应用和 MongoDB 数据库的容器化版本。

    注意,网页应用使用名为 nginx.Dockerfile 的新文件进行容器化。

  • 导航到 http://localhost:8080 查看网页应用。

    要登录,单击填写按钮以使用默认的演示凭据填写电子邮件和密码字段。

  • 导航到 http://localhost:3000 查看服务器着陆页面:手机截图  自动生成的描述

    图 7.1:LemonMart 服务器着陆页面

  • 导航到 http://localhost:3000/api-docs 查看交互式 API 文档。

  • 您可以使用 npm run start:database 仅启动数据库,并在 server 文件夹中使用 npm start 进行调试。

  • 您可以使用 npm run start:backend 仅启动数据库和服务器,并在 web-app 文件夹中使用 npm start 进行调试。

对于第七章中的客户端实现:

  • 克隆仓库:github.com/duluca/lemon-mart

  • 在根目录中执行 npm install 以安装依赖项。

  • 项目的初始状态反映在:

    projects/stage8 
    
  • 项目的最终状态反映在:

    projects/stage10 
    
  • 将阶段名称添加到任何 ng 命令中,以仅对该阶段执行操作:

    npx ng build stage10 
    

注意,仓库根目录下的 dist/stage10 文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能不同。这些项目周围的生态系统不断演变。由于 Angular CLI 生成新代码的方式、错误修复、库的新版本以及多种技术的并行实现,存在许多难以计数的变体。如果您发现错误或有疑问,请在 GitHub 上创建问题或提交拉取请求。

当您的 LemonMart 服务器启动并运行时,我们准备探索 MEAN 栈的架构。到本节结束时,您应该有自己的 LemonMart 版本与服务器通信。

全栈架构

全栈指的是使应用程序工作的整个软件堆栈,从数据库到服务器、API 以及利用它们的 Web 和/或移动应用程序。传说中的全栈开发者无所不知,可以轻松地在职业的各个垂直领域操作。在所有与软件相关的事物上专长并被认为是每个给定主题的专家几乎是不可能的。然而,要被认为是某个主题的专家,你也必须对相关主题有深入的了解。在了解一个新主题时,保持你的工具和语言一致非常有帮助,这样你就可以在没有额外噪音的情况下吸收新信息。

因此,我选择向你介绍 MEAN 堆栈,而不是使用 Java 的 Spring Boot 或使用 C#的 ASP.NET。通过坚持熟悉的工具和语言,如 TypeScript、VS Code、npm、GitHub、Jasmine/Jest、Docker 和 CircleCI,你可以更好地理解全栈实现是如何结合在一起的,并成为一个更好的 Web 开发者。

最小化 MEAN

为你的项目选择理想的堆栈是困难的。首先,你的技术架构应该足够满足业务需求。例如,如果你试图使用 Node.js 交付一个人工智能项目,你很可能会使用错误的堆栈。我们的重点将是交付 Web 应用程序,但除此之外,我们还有其他参数需要考虑,包括以下内容:

  • 易用性

  • 幸福

  • 效率

如果你的开发团队将长期从事你的应用程序开发,考虑兼容性以外的因素非常重要。如果你的代码库易于使用,让你的开发者保持愉快,或者让他们觉得自己是项目的有效贡献者,你的堆栈、工具选择和编码风格可以产生重大影响。

一个良好配置的堆栈对于优秀的 DevEx 至关重要。这可能是干燥的煎饼堆和美味的小份煎饼之间的区别,适量的黄油和糖浆。

通过引入过多的库和依赖项,你可以减慢你的进度,使你的代码难以维护,并发现自己陷入引入更多库以解决其他库问题的反馈循环。赢得这场游戏的唯一方法就是简单地不参与。

如果你花时间学习如何使用几个基本的库,你可以成为一个更有效的开发者。本质上,你可以用更少的资源做更多的事情。我的建议是:

  • 在编写任何一行代码之前思考,并应用 80-20 规则。

  • 等待库和工具成熟,跳过测试版。

  • 快速通过减少对新包和工具的贪婪,掌握基础知识。

在 YouTube 上观看我 2017 年 Ng 会议的演讲,标题为用更少的 JavaScript 做更多的事情,链接为www.youtube.com/watch?v=Sd1aM8181kc

这种极简主义思维是最小化 MEAN 的设计哲学。您可以在 GitHub 上查看参考实现:github.com/duluca/minimal-mean。请参考以下图表以了解整体架构:

图 7.2:最小化 MEAN 软件栈和工具

让我们回顾一下架构的组件:

  • Angular: 您应该知道这个。Angular 是表示层。Angular 构建的输出是一组静态文件,可以使用最小化的 Docker 容器duluca/minimal-nginx-web-serverduluca/minimal-node-web-server托管。

  • Express.js: 这是我们的 API 层。Express 是一个快速、无偏见、极简的 Node.js 网络框架。Express 拥有庞大的插件生态系统,几乎可以满足每一个需求。NestJS 建立在 Express 之上,是成熟团队的不错替代品。在最小化 MEAN 中,我们利用了一些 Express 中间件:

    • cors: 配置跨源资源共享设置

    • compression: 压缩通过网络发送的数据包以降低带宽使用

    • morgan: 记录 HTTP 请求

    • express.static: 用于提供public文件夹内容的函数

    • graphql: 用于托管 GraphQL 端点

您可以在expressjs.com/了解更多关于 Express.js 的信息

  • Node.js: 这是服务器运行时;Express 在 Node 上运行,因此业务层将在 Node 上实现。Node 是一个轻量级且高效的 JavaScript 运行时,它使用事件驱动的、非阻塞的 I/O 模型,适用于高性能和实时应用。您可以通过使用 TypeScript 开发应用程序来提高 Node 应用程序的可靠性。

    Node 可以在任何地方运行,从冰箱到智能手表。请参阅 Frank Rosner 的博客文章,深入了解非阻塞 I/O 主题:blog.codecentric.de/en/2019/04/explain-non-blocking-i-o-like-im-five/

  • MongoDB: 这是持久化层。MongoDB 是一个具有动态 JSON 类似模式的文档型数据库。有关 MongoDB 的更多信息,请参阅www.mongodb.com/

MEAN 堆栈更受欢迎,因为它利用了使用基于 JSON 的数据库的主要好处,这意味着你不需要将数据从一种格式转换到另一种格式,因为它跨越了你的堆栈层——在处理.NET、Java 和 SQL 服务器时这是一个主要痛点。你可以仅使用 JSON 来检索、显示、编辑和更新数据。此外,Node 的 MongoDB 原生驱动程序成熟、性能良好且功能强大。我开发了一个名为document-ts的库,旨在通过引入易于编码的丰富文档对象来简化与 MongoDB 的交互。DocumentTS 是一个非常薄的基于 TypeScript 的 MongoDB 助手,具有可选的丰富 ODM 便利功能。更多关于 DocumentTS 的信息请参阅github.com/duluca/document-ts

Minimal MEAN 利用了我们用于 Angular 开发的相同工具和语言,这使得开发者可以在前端和后端开发之间进行最小化的上下文切换。

NestJS

Minimal MEAN 有意坚持基本原理,这样你可以更多地了解底层技术。虽然我使用 Minimal MEAN 为具有不同技能水平的大型团队交付了生产系统,但这种基础的开发体验可能并不合适。在这种情况下,你可能考虑 NestJS,这是一个用于实现全栈 Node.js 应用的流行框架。NestJS 具有丰富的功能集,其架构和编码风格类似于 Angular。

想要冒险吗?通过执行以下命令创建一个 NestJS 应用:

$ npx @nestjs/cli new your-app-name --strict 

Nest 建立在 Express 之上,并提供了构建可扩展后端解决方案的语法糖和概念。该框架大量借鉴了 Angular 的思想来实现依赖注入、守卫、拦截器、管道、模块和提供者。内置的资源生成器可以生成实体类、CRUD创建检索更新删除)控制器、数据传输对象DTOs)和服务。

例如:

$ npx nest g resource users 

在创建资源时,你可以选择创建 REST、GraphQL、微服务或 WebSocket 端点:

? What transport layer do you use?
> REST API 
  GraphQL (code first)
  GraphQL (schema first)
  Microservice (non-HTTP)
  WebSockets 

Nest 支持 OpenAPI 用于 REST 文档,GraphQL 也支持 GraphQL 的 schema-first 和 code-first 开发。对于具有如此多功能的库,Nest 的显式微服务支持是受欢迎的,快速启动时间和小框架大小对于操作至关重要。所有这些功能都由详细的文档在docs.nestjs.com/中支持。

向 Kamil Mysliwiec 和 Mark Pieszak 致敬,他们创建了一个伟大的工具,并在 NestJS 周围培养了一个充满活力的社区。如果你需要,可以在trilon.io/寻求咨询服务。

如果你访问文档网站,可能会被提供的众多选项所淹没。这就是为什么我在你用最少的 MEAN 掌握了基础知识之后,推荐使用功能丰富的库的原因。

你可以在nestjs.com/了解更多关于 NestJS 的信息。

接下来,让我们了解 monorepo、它们的优点和缺点。我将分享如何在 monorepo 中结合 Nx、Nest 和 Angular,然后介绍 LemonMart 服务器如何使用 Git 子模块创建 monorepo。

在 VS Code 中使用多根工作区

monorepo单体仓库)是一种软件开发策略,用于在单个仓库中托管多个项目的代码。这允许统一版本控制、简化依赖关系管理,以及更容易地在项目之间共享代码。在 monorepo 中,开发者可以在同一个 IDE 窗口中跳转项目,并更容易地在项目之间引用代码,例如在前端和后端之间共享 TypeScript 接口,确保数据对象每次都保持一致。

你可以使用 VS Code 中的多根工作区在同一个 IDE 窗口中启用对多个项目的访问,你可以在资源管理器窗口中添加多个项目进行显示。然而,monorepo 在源代码控制级别将项目组合在一起,允许我们在 CI 服务器上一起构建它们。有关多根工作区的更多信息,请参阅code.visualstudio.com/docs/editor/multi-root-workspaces

能够访问多个项目的代码使得提交原子更改成为可能,这意味着跨项目所做的更改可以合并为一个单独的提交。这通过将可能需要在多个仓库、部署和系统中协调的更改集中在一个地方,带来明显的优势。所有围绕维护代码质量和标准的过程也变得简化。只有一个Pull RequestPR)需要审查,一个部署需要验证,以及一组需要执行的检查。

那么为什么每个项目都不是 monorepo 呢?在大型的应用程序中,项目中的文件过多可能成为一个重大问题。它要求每个开发者都拥有顶级的硬件和 CI/CD 服务器,以便在昂贵的、高性能的硬件上运行。此外,自动部署这样的项目可能成为一个非常复杂的任务。最后,新加入团队的新成员可能会感到不知所措。

虽然 monorepos 至少可以追溯到 2000 年代初,但对于大多数公司来说,除了全球顶尖的科技公司外,它们并不实用。2019 年,当谷歌发布了开源的 Bazel 构建工具,该工具基于 2015 年的内部项目 Blaze 时,这个想法对于小规模项目来说变得可行。在 JavaScript、TypeScript 和 Web 应用程序开发领域,由前谷歌员工开发的 Nx 已经崭露头角。在管理、构建和发布包方面,Lerna 是 Nx 的近亲。

Nx monorepo

如同在第三章中提到的,构建企业级应用架构,Nx 是一个下一代构建系统,具有一流的单一代码仓库支持和强大的集成功能。Nx 提供了一种有见地的架构,这对于大型团队和企业来说是非常受欢迎的。Nx 还提供云服务,它将利用分布式缓存和并行化来优化构建,而无需你的团队投资复杂的底层基础设施工作。

你可以通过执行以下命令来设置一个新的 Nx 工作空间:

$ npx create-nx-workspace@latest 

或者,你可以在项目文件夹中执行以下命令来迁移现有项目:

$ npx nx@latest init 

默认情况下,这将为你提供一个包含一个应用的单一代码仓库配置。你可以使用 Nx 生成器添加可以在组件和其他模块之间共享的库。通过将代码分离到不同的库中,同时参与项目工作的多个人不太可能遇到合并冲突。然而,如果你遵循先路由架构并在功能模块之间划分职责,你也能得到类似的结果。更多内容请参阅nx.dev/getting-started

问题是,这值得吗?许多专家将其用作标准工具;然而,在我追求简约的过程中,我不喜欢在刀战中带来坦克。引入这样复杂的技术对团队来说是有成本的。采用这样的工具需要克服陡峭的学习曲线。

当你在 JavaScript、TypeScript、Git、Nx、Angular、库、Node、npm 和其他服务器端技术之上层层叠加时,导航这些工具所需的认知负荷会急剧增加。此外,这些工具中的每一个都需要专业知识来正确配置、维护和随着时间的推移进行升级。

在现代硬件上(至少不是被企业级慢速一切以便我们可以额外确保你没有病毒软件搞砸的硬件),拥有数百个组件的 Angular 应用构建速度足够快。随着 esbuild 和 Vite 的采用,这应该会进一步改善。Nx 的分布式缓存和集中式依赖管理功能可能会对你产生决定性影响。在开始一个新项目之前,务必仔细评估你的需求;自动运行时,很容易低估或高估你的需求。

我要明确一点。如果你正在处理数千个组件,那么 Nx 是必需的。

大多数 Angular 单一代码仓库只包含前端代码。要在现有的 Angular 工作空间中使用 NestJS 配置一个全栈单一代码仓库,请安装 Nest 脚本并在 Nx 工作空间内生成一个新项目:

$ npm i -D @nrwl/nest
$ npx nx g @nrwl/nest:application apps/your-api 

你可以在此处了解更多信息www.thisdot.co/blog/nx-workspace-with-angular-and-nest/

接下来,让我们看看 LemonMart 服务器的单一代码仓库是如何配置的。

Git 子模块

Git 子模块帮助您在多个仓库之间共享代码,同时保持提交的分离。前端开发者可能选择仅使用前端仓库进行工作,而全栈开发者将更喜欢访问所有代码。Git 子模块还为现有项目的合并提供了一个方便的方法。

观察一下lemon-mart-server项目的整体结构,您将拥有三个主要文件夹,如图所示:

lemon-mart-server
├───bin
├───web-app (snapshot of lemon-mart)
├───server
│   package.jsonREADME.md 

bin文件夹包含辅助脚本或工具,web-app文件夹代表您的前端,而server包含后端源代码。在我们的案例中,web-app文件夹是lemon-mart项目。我们不是复制粘贴现有项目的代码,而是利用 Git 子模块将两个仓库链接在一起。package.json文件包含帮助初始化、更新和清理 Git 子模块的脚本,如modules:update用于获取 web 应用的最新版本。

我建议您在从 GitHub 克隆的lemon-mart-server版本上执行以下操作。否则,您将需要创建一个新的项目并执行npm init -y以开始操作。

要使用您的项目初始化 web-app 文件夹:

  1. webAppGitUrl更新为您自己的项目的 URL。

  2. 执行webapp:clean以删除现有的web-app文件夹。

  3. 最后,执行webapp:init命令以初始化web-app文件夹中的项目:

    $ npm run webapp:init 
    

在继续前进时,执行modules:update命令以更新子模块中的代码。在另一个环境中克隆仓库后,要拉取子模块,请执行npm modules:init。如果您需要重置环境并重新启动,请执行webapp:clean以清理 Git 的缓存并删除文件夹。

注意,您可以在您的仓库中拥有多个子模块。modules:update命令将更新所有子模块。

您的 Web 应用程序代码现在可在名为web-app的文件夹中找到。此外,您应该能够在 VS Code 的源代码控制面板下看到这两个项目,如图所示:

图片

图 7.3:VS Code 源代码控制提供者

使用 VS Code 的源代码控制,您可以对任一仓库独立执行 Git 操作。

如果您的子模块变得混乱,只需cd到子模块目录,执行git pull,然后git checkout main以恢复主分支。使用此技术,您可以从项目中的任何分支检出并提交 PR。

现在子模块已经准备好了,让我们看看服务器项目是如何配置的,这样我们就可以配置我们的 CI 服务器。

CircleCI 配置

使用 Git 子模块的一个好处是我们可以验证我们的前端和后端是否在同一个 CI 管道中工作。config.yml文件实现了两个作业,这是这里显示的工作流程的一部分:

**.circleci/config.yml**
...
workflows:
  build-and-test-compose:
    jobs:
      - build_server
      - build_webapp 

管道检出代码,使用 audit-ci 验证我们使用的包的安全性,安装依赖项,检查样式和 linting 错误,运行测试,并检查代码覆盖率水平。

测试命令隐式构建服务器代码,这些代码存储在 dist 文件夹下。在最后一步,我们将 dist 文件夹移动到工作区,以便我们可以在以后阶段使用它。

CI 管道将并行构建服务器和 Web 应用程序,如果主分支上的作业成功,可以选择运行 deploy 作业。关于 CI/CD 的更多细节可以在 第十章,使用 CI/CD 发布到生产 中找到。

接下来,让我们看看 RESTful 和 GraphQL API 之间的区别。

设计 API

第三章,构建企业应用程序架构 中,我讨论了无状态、数据驱动设计作为 Router-first 架构的一部分的重要性。作为这一目标的一部分,我强调识别应用程序将围绕其操作的主要数据实体作为一项重要活动。API 设计通过围绕主要数据实体进行设计也能带来极大的好处。

在全栈开发中,尽早确定 API 设计非常重要。如果前端和后端团队能够就主要数据实体及其形状达成一致,那么两个团队就可以就一个合同达成一致,去构建他们各自的软件组件。在 Router-first 架构中,我强调了利用 TypeScript 接口快速构建应用程序架构的重要性。后端团队也可以进行类似的活动。

一点早期的设计工作和协议确保了这些组件之间的集成可以非常早地建立,并且通过 CI/CD 管道,我们可以确保它不会分解。

CI 对于成功至关重要。最臭名昭著的案例之一是,团队直到太晚才整合关键系统,那就是 2013 年 HealthCare.gov 的灾难性发布。尽管有 300 人参与其中,并且在这个项目上花费了 3 亿美元,但它失败了。总共花费了 17 亿美元来拯救该项目并使其成功。美国政府可以承担这样的费用。您的企业可能不会这么宽容。

在设计您的 API 时,还有一些进一步的考虑因素,如果前端和后端开发者紧密合作以实现共同的设计目标,那么项目成功的几率将大大提高。

列出以下高级目标:

  • 最小化客户端和服务器之间传输的数据。

  • 坚持使用成熟的设计模式(例如,分页 API 设计)。

  • 设计以减少客户端上的业务逻辑实现。

  • 围绕主要数据实体进行设计。

  • 在跨越边界时简化数据结构。

  • 不要暴露数据库密钥或外键关系。

  • 从一开始就版本化端点。

你应该旨在实现 API 表面背后的所有业务逻辑。前端应仅包含展示逻辑。任何由前端实现的if语句也应由后端验证。

如在第一章Angular 的架构和概念中讨论的那样,在后台和前端实现无状态设计至关重要。每个请求都应该利用非阻塞 I/O 方法,并且不依赖于现有的会话。这是在云平台上无缝扩展你的 Web 应用程序代码的关键。会话因其扩展和占用大量内存而臭名昭著。

无论何时你在实施一个项目,限制,如果可能的话,消除实验是非常重要的。这在全栈项目中尤其如此。一旦你的应用程序上线,API 设计中的失误可能会产生深远的影响,并且难以纠正。概念验证是实验和验证想法以及新技术理想的地方。它们的一个显著特点是它们的可丢弃性。

接下来,让我们来讨论围绕主要数据实体设计 REST 和 GraphQL API。在这种情况下,我们将回顾围绕用户和认证的 API 实现。在两种情况下,我们将依赖 API 规范语言。对于 REST,我们将使用 OpenAPI 规范,对于 GraphQL,我们将使用模式规范,以记录设计,以便我们可以具体地向团队成员传达 API 的意图。稍后,这些规范将成为交互式工具,反映我们 API 的能力。

REST API

REST表示状态转移)通常用于创建利用 HTTP 方法(动词)如GETPOSTPUTDELETE的无状态、可靠的 Web 应用程序。REST API 定义良好且静态。像任何公开 API 一样,一旦发布,就很难,如果不是不可能的,改变它们的接口。总是可以扩展,但很难针对新兴用例进行优化,例如需要以不同方式使用 API 的移动或专用应用程序。这通常会导致 API 表面的巨大扩展,因为团队实施特定的 API 来满足新的需求。如果有多个独立的代码库需要访问相同的数据,这可能会导致可维护性挑战。

从前端开发者的角度来看,使用他们没有编写过的 API 可能是一种令人困惑的经历。大多数公开 API 和发布 API 的企业通常通过发布高质量的文档和示例来解决这个问题。这需要时间和金钱。然而,在企业环境中,一个快速发展的团队无法等待这样的文档被手动创建。

进入 OpenAPI,也称为 Swagger。OpenAPI 规范可以记录 API 名称、路由、输入和返回参数类型、编码、身份验证、请求头和预期的 HTTP 状态码。这种详细程度为 API 应如何使用留下了很少的解释空间,减少了摩擦和有缺陷的代码——所有这些都是避免后期集成挑战的关键因素。

OpenAPI 规范可以用 YAML 或 JSON 格式定义。使用此规范文件,您可以为您 API 渲染一个交互式用户界面。安装 Swagger Viewer VS Code 扩展,并在 server 文件夹下预览 swagger.yaml 文件:

此外,还有 OpenAPI (Swagger) 编辑器扩展,这是一个功能丰富的替代品。在发布时,此扩展不支持 OpenAPI 版本 3.1.0。

计算机屏幕截图  描述自动生成

图 7.4:Swagger.yaml 预览

使用 Swagger UI 视图,您可以在实现后尝试命令并对其服务器环境执行它们。

OpenAPI 规范

我们使用 OpenAPI 规范版本 openapi: 3.1.0。OpenAPI 规范可以记录关于您的服务器、API 的各种组件(如安全方案、响应、数据模式、输入参数)以及定义您的 HTTP 端点的路径的元数据。

让我们回顾一下位于 server 文件夹下的 swagger.yaml 文件的主要组件:

  1. YAML 文件以一般信息和目标服务器开始:

    **server/swagger.yaml**
    openapi: 3.1.0
    info:
      title: lemon-mart-server
      description: LemonMart API
      version: "3.0.0**"**
    **servers**:
      - url: http://localhost:3000
        description: Local environment
      - url: https://mystagingserver.com
        description: Staging environment
      - url: https://myprodserver.com
        description: Production environment 
    
  2. components 下,我们定义常见的 securitySchemes 和响应,这些定义了我们打算实施的认证方案以及我们的错误消息响应的外观:

    **server/swagger.yaml**
    **...**
    **components:**
      **securitySchemes:**
        bearerAuth:
          type: http
          scheme: bearer
          bearerFormat: JWT
      **responses:**
        UnauthorizedError:
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServerMessage"
              type: string 
    

    注意 $ref 的使用,它可以重复使用重复的元素。您可以看到在这里定义了 ServerMessage

  3. components 下,我们定义共享的数据 schemas,这些声明了我们作为输入接受的或返回给客户端的数据实体:

    **server/swagger.yaml**
    ...
      **schemas:**
        ServerMessage:
          type: object
          properties:
            message:
              type: string
        Role:
          type: string
          enum: [none, clerk, cashier, manager]
        ... 
    
  4. components 下,我们定义共享的 parameters,这使得重用如分页端点等常见模式变得容易:

    **server/swagger.yaml**
    ...
      **parameters:**
        filterParam:
          in: query
          name: filter
          required: false
          schema:
            type: string
          description: Search text to filter the result set by
    ... 
    
  5. paths 下,我们定义 REST 端点,例如 /login 路径的 post 端点:

    **server/swagger.yaml**
    ...
    **paths:**
      /v1/login:
        post:
          description: |
            Generates a JWT, given the correct credentials.
          requestBody:
            required: true
            content:
              application/json:
                schema:
                  type: object
                  properties:
                    email:
                      type: string
                    password:
                      type: string
                  required:
                    - email
                    - password
          responses:
            '200': # Response
              description: OK
              content:
                application/json:
                  schema:
                    type: object
                    properties:
                      accessToken:
                        type: string
                    description: JWT token that contains userId as subject, email and role as data payload.
            '401':
              $ref: '#/components/responses/UnauthorizedError' 
    

    注意 requestBody 定义了类型为 string 的必需输入变量。在 responses 下,我们可以定义对请求的 200 成功响应和 401 不成功响应的外观。在前者的情况下,我们返回 accessToken,而在后者的情况下,我们返回在 步骤 2 中定义的 UnauthorizedError

  6. paths 下,我们定义剩余的路径:

    **server/swagger.yaml**
    ...
    **paths:**
      /v1/auth/me:
        get: ...
      /v2/users:
        get: ...
        post: ...
      /v2/users/{id}:
        get: ...
        put: ... 
    

OpenAPI 规范功能强大,允许您定义复杂的用户如何与您的 API 交互的要求。OpenAPI 规范可在 spec.openapis.org/oas/latest.html 找到。在开发自己的 API 定义时,这是一个无价的资源。

我们的总体目标是集成此交互式文档与我们的 Express.js API。现在,让我们看看您如何实现这样的 API。

OpenAPI 规范与 Express

使用 Express 配置 Swagger 是一个手动过程。但这是一件好事。强迫自己手动记录端点有积极的影响。通过放慢速度,你将有机会从 API 消费者的角度考虑你的实现。这种视角将帮助你解决开发过程中端点可能存在的潜在问题,避免令人烦恼的,如果不是昂贵的,返工。

让我们看看如何将 OpenAPI 规范直接嵌入到代码中的示例:

**server/src/v1/routes/authRouter.****ts**
/**
 * @openapi
 * /v1/auth/me:
 *   get:
 *     description: Gets the `User` object of the logged in user
 *     responses:
 *       '200':
 *         description: OK
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/User'
 *       '401':
 *         $ref: '#/components/responses/UnauthorizedError'
 */
router.get('/me', authenticate(), async (_req: Request, res: Response) => {
  if (res.locals.currentUser) {
    return res.send(res.locals.currentUser)
  }
  return res.status(401).send({ message: AuthenticationRequiredMessage })
}) 

在本例中,我们使用以 /** 开头的 JSDoc 文档语法,然后在 @openapi 标识符之后直接定义 OpenAPI 规范的相关部分。我们仍然可以引用其他地方定义的组件,如通过 $ref 语句引用 UserUnauthorizedError 对象所示。

将规范集成到代码旁边的最大好处是,开发者确切地知道服务器应该如何响应 /me GET 请求。如果存在用户,我们返回一个 User 对象;如果没有,我们抛出一个符合 UnauthorizedError 对象形状的 401 错误。使用一些自动化工具,我们仍然可以生成之前提到的相同的交互式 Swagger UI,因此测试人员和开发者可以直接从 Web 界面发现或测试 API。

随着 API 实现的演变,这种设置使开发者能够轻松地保持规范更新。通过使其变得容易,我们激励所有相关人员都有保持 Swagger UI 运作的动力,因为所有团队成员都从中受益。通过创建一个良性循环,我们实现了活文档的理想。通常,随着其变得过时,初始设计变得无用,但相反,我们可以有一个自动化和交互式的解决方案,提供持续的价值。

我们将使用两个辅助库来帮助我们集成内联规范到代码库中:

  • swagger-jsdoc:这允许我们通过在 JSDoc 注释块中使用 @openapi 标识符,在相关代码上方实现 OpenAPI 规范,输出一个 swagger.json 文件。

  • swagger-ui-express:这个库消耗 swagger.json 文件以显示交互式的 Swagger UI Web 界面。

让我们探索 Swagger 如何配置与 Express.js 一起工作:

  1. TypeScript 的依赖和类型信息如下所示:

    $ npm i swagger-jsdoc swagger-ui-express
    $ npm i -D @types/swagger-jsdoc @types/swagger-ui-express 
    
  2. 让我们探索 docs-config.ts 文件,它配置了基本的 OpenAPI 定义:

    **server/src/docs-config.****ts**
    import * as swaggerJsdoc from 'swagger-jsdoc'
    import { Options } from 'swagger-jsdoc'
    import * as packageJson from '../package.json'
    const options: Options = {
      swaggerDefinition: {
        openapi: '3.1.0',
        components: {},
        info: {
          title: packageJson.name,
          version: packageJson.version,
          description: packageJson.description,
        },
        servers: [
          {
            url: 'http://localhost:3000',
            description: 'Local environment',
          },
          {
            url: 'https://mystagingserver.com',
            description: 'Staging environment',
          },
          {
            url: 'https://myprodserver.com',
            description: 'Production environment',
          },
        ],
      },
      apis: [
        '**/models/*.js', 
        '**/v1/routes/*.js', 
        '**/v2/routes/*. js'
      ],
    }
    export const specs = swaggerJsdoc(options) 
    

    修改 servers 属性以包含你的测试、预发布或生产环境的位置。这允许 API 的消费者使用 Web 界面测试 API,而无需额外的工具。请注意,apis 属性通知代码文件 swaggerJsdoc 在构建 swagger.json 文件时应解析的文件。这个程序在服务器引导过程中运行,这就是为什么我们引用了转译的 .js 文件而不是 .ts 文件。

  3. app.ts 中引导 swagger 配置:

    **server/src/app.****ts**
    import * as swaggerUi from 'swagger-ui-express'
    import { specs } from './docs-config'
    const app = express()
    app.use(cors())
    ...
    **app.****use****(****'/api-docs'****, swaggerUi.****serve****, swaggerUi.****setup****(specs))**
    **app.****get****(****'/swagger'****,** **function** **(****_req, res****) {**
     **res.****json****(specs)**
    **})**
    ...
    export default app 
    

规范包含swagger.json文件的内容,然后传递给swaggerUi。然后,使用server中间件,我们可以配置swaggerUi/api-docs上托管 Web 界面。我们还可以从端点提供 JSON 文件,以便在其他工具中使用,如上所示。

即使在将规范文件与代码集成之后,开发者也必须手动确保规范和代码的一致性。这个过程可以自动化,包括生成基于 TypeScript 的 API 处理程序以防止编码错误。

可以在openapi.tools/找到由社区驱动的 OpenAPI 高质量和现代工具列表。

现在你已经了解了我们如何设计 REST API 并在其周围创建活文档,现在是时候学习 GraphQL 了,它将这些想法融入其核心设计。

GraphQL API

GraphQL图查询语言),由 Facebook 发明,是一种现代的 API 查询语言,它提供了一种比传统 REST API 更灵活、更健壮、更高效的替代方案。在 GraphQL 中,你不需要使用 HTTP 动词,而是编写一个查询来获取数据,一个突变来 POST、PUT 或 DELETE 数据,以及订阅以 WebSocket 风格推送数据。与 REST 不同,REST 为每个资源暴露一组固定的端点,而 GraphQL 允许客户端请求他们确切需要的数据,不多也不少。这意味着客户端可以根据他们的需求来塑造响应,从而减少过度获取和不足获取的问题。我们不再需要设计完美的 API 表面来获得最佳结果。

在全栈开发领域,正如在设计 API部分中提到的,围绕主要数据实体进行设计的重要性不容忽视。GraphQL 在这方面表现出色。其类型系统确保 API 围绕这些主要数据实体进行塑造,为前端和后端团队提供了一个清晰的合同。这个类型系统,定义在 GraphQL 模式中,作为合同,指定可以获取的数据类型和可用的操作集。

对于前端开发者来说,深入探索 GraphQL API 可以是一种令人耳目一新的体验。GraphQL 的反思性意味着可以查询其自身的模式以获取详细信息。

这种自文档特性确保开发者始终拥有最新的参考,消除了需要单独手动维护文档的需求。这对于企业环境中的敏捷团队特别有益,在这些环境中,等待文档并不总是可行的。

进入 GraphQL Playground 或 GraphiQL 交互式环境,开发者可以实时测试和探索 GraphQL 查询。这些工具与 OpenAPI 的 Swagger UI 类似,提供即时反馈,使开发者能够理解 API 的结构、类型和操作。这种动手方法降低了学习曲线,并促进了开发者对 API 功能的更深入理解。

接下来,让我们探索如何围绕主要数据实体设计 GraphQL API,确保它们与我们在 Router-first 架构和其他最佳实践中概述的原则保持一致。

GraphQL 模式

GraphQL 模式是任何 GraphQL API 的核心,作为客户端和服务器之间的合约。它通过定义类型和类型之间的关系来描述 API 的结构和能力。这些类型模拟了 API 操作的主要数据实体。

让我们从探索位于 server/graphql 下的 graphql.schema 文件开始:

  1. 使用 type 关键字,我们可以定义数据对象:

    **server/graphql/graphql.schema**
    type User {
      address: Address
      dateOfBirth: String
      email: String!
      id: ID!
      level: Float
      name: Name!
      phones: [Phone]
      picture: String
      role: Role!
      userStatus: Boolean!
      fullName: String
    } 
    

    这个 User 类型具有标量字段,如 idemail 字段,代表原始值类型如 IDStringIntFloatBoolean。感叹号 ! 表示这些字段是必需的。我们还可以定义类型之间的关系,例如 NamePhone。方括号 [] 表示 phonesPhone 对象的数组。

  2. 我们还可以定义枚举并像标量类型一样使用它们:

    **server****/****graphql****/****graphql.schema**
    enum Role {
      None
      Clerk
      Cashier
      Manager
    } 
    
  3. 使用保留类型 Query,我们可以定义如何检索数据:

    **server****/****graphql****/****graphql.schema**
    type Query {
      # Gets a `User` object by id
      # Equivalent to GET /v2/users/{id}
      user(id: ID!): User 
    } 
    

    我们可以定义可接受的参数和返回类型。

  4. 使用保留类型 Mutation,我们可以定义如何修改状态:

    **server/graphql/graphql.schema**
    type Mutation {
      # Generates a JWT, given correct credentials.
      # Equivalent to POST /v1/auth/login
      login(email: String!, password: String!): JWT
      # Create a new `User`
      # Equivalent to POST /v2/users
      createUser(userInput: UserInput!): User
    } 
    

    我们可以定义一个登录或 createUser 方法。注意,createUser 接受一个输入对象,如果我们想传递整个对象作为参数,则该对象是必需的。

  5. 输入对象使用 input 关键字声明:

    **server/graphql/graphql.schema**
    input UserInput {
      address: AddressInput
      dateOfBirth: String
      email: String!
      level: Float
      name: NameInput!
      phones: [PhoneInput]
      picture: String
      role: Role!
      userStatus: Boolean!
    } 
    

注意,任何相关对象也必须使用输入声明。输出类型和输入数据不能混合。

如您可能已经注意到的,我们还可以使用 # 符号或可选的三重引号 """ 语法添加描述来记录我们的 API。

模式使用 GraphQL 模式定义语言SDL)定义。您可以在 graphql.org/ 访问 SDL 规范。它是任何构建良好定义的 GraphQL API 的人的必备资源。

总体而言,该模式在客户端和服务器之间提供了一个严格的合约。它明确提供了可用的数据形状和能力。前端和后端团队可以针对此合约并行构建功能,并且像 GraphQL Playground 这样的工具使得模式交互式。

我们将使用 Apollo GraphQL 库来帮助在我们的 Express 服务器中以编程方式构建模式。

Apollo 与 Express

Apollo GraphQL 是一套全面且广泛采用的工具和服务套件,旨在帮助开发者轻松构建、管理和扩展 GraphQL 应用程序。由 Meteor 开发组开发,Apollo 由于其强大的功能和开发者友好的方法,已成为许多开发者进行 GraphQL 开发的同义词。以下是 Apollo GraphQL 的概述:

  • Apollo Client:一个先进的 GraphQL 客户端,用于管理本地和远程数据。它可以无缝集成到任何 JavaScript 前端框架中,如 React、Vue 或 Angular。Apollo Client 提供了缓存、乐观 UI 更新和实时订阅等功能,使得获取、缓存和修改应用程序数据变得更加容易。

  • Apollo Server:一个由社区驱动的开源 GraphQL 服务器,可以与任何 GraphQL 模式一起工作。Apollo Server 提供性能跟踪和错误跟踪,并支持模式拼接,允许将多个 GraphQL API 合并成一个统一的 API。

  • Apollo Client 开发者工具:提供丰富的浏览器内开发体验的浏览器扩展。开发者可以查看他们的 GraphQL 存储,检查活动查询,并使用内置的 GraphiQL IDE 与他们的 GraphQL 服务器进行交互。

Apollo 作为其云服务的一部分提供了更高级的开发工具,即 Apollo Studio。Apollo Federation 允许组织将他们的单体 GraphQL API 划分为更小、更易于维护的微服务。它提供了一种将多个 GraphQL 服务组合成单个数据图的方法。Apollo Link 允许开发者创建可链式的“链接”来处理日志记录、请求重试甚至离线缓存等任务。

从本质上讲,Apollo GraphQL 提供了一种全面的 GraphQL 开发方法,为初学者和高级用户提供工具和服务。无论您是构建小型应用程序还是扩展大型企业系统,Apollo 的工具都能确保出色的开发体验。

GraphQL 模式和 GraphQL 库是不可分割的,因此我们不需要采取额外步骤来配置模式定义以与代码库一起工作,就像我们与 OpenAPI 一起做的那样。

要从 GraphQL 模式生成类型,请遵循 www.apollographql.com/docs/apollo-server/workflow/generate-types/ 提供的指南。

接下来,让我们看看如何使用 Express.js 配置您的模式和 Apollo:

  1. 安装 Apollo 服务器:

    $ @apollo/server 
    
  2. 打开 api.graphql.ts 文件,该文件配置了 Apollo 服务器:

    **server/src/graphql/api.****graphql****.****ts**
    ...
    import { resolvers } from './resolvers'
    const typeDefs = readFileSync('./src/graphql/schema.graphql', 
    ...
    export async function useGraphQL(app: Express) {
      const server = new ApolloServer<AuthContext>({
        typeDefs,
        resolvers,
      })
      await server.start()
      ...
      )
    } 
    
  3. 使用 node:fs,我们将模式文件读取到 typeDefs 对象中,并将其传递给一个新的 ApolloServer 实例,同时传递一个对解析器的引用。最后,我们调用 server.start() 并导出 useGraphQL 函数。

  4. index.ts 中启动 Apollo 服务器:

    **server/src/index.****ts**
    import app from './app'
    ...
    **async****function****start****() {**
      **...**
      **Instance** **= http.****createServer****(app)**
      **await****useGraphQL****(app)**
            ...
          }
          start() 
    

index.ts 中,在我们创建由 app 变量定义的 Express 服务器实例之后,我们调用 useGraphQL 函数来启动它。这种配置允许我们同时实现 REST 和 GraphQL API。如以下所示,GraphQL API 和交互式探索工具可以通过 /graphql 访问:

计算机截图  描述自动生成

图 7.5:GraphQL 探索器

现在您已经了解了 REST 和 GraphQL API 之间的区别以及我们如何使用 Express.js 等效地配置它们,让我们看一下服务器的整体架构。

使用 Express.js 实现 API

让我们回顾一下我们后端的结构和文件结构,以便我们了解服务器是如何启动的,API 端点的路由是如何配置的,公共资源是如何提供的,以及服务是如何配置的。

查看我们的 Express 服务器文件结构:

server/src
├── api.ts
├── app.ts
├── config.ts
├── docs-config.ts
├── graphql
│   ├── api.graphql.ts
│   ├── helpers.ts
│   └── resolvers.ts
├── index.ts
├── models
│   ├── enums.ts
│   ├── phone.ts
│   └── user.ts
├── public
├── services
│   ├── authService.ts
│   └── userService.ts
├── v1
│   ├── index.ts
│   └── routes
│       └── authRouter.ts
└── v2
    ├── index.ts
    └── routes
        └── userRouter.ts 

接下来,我们将通过查看组件图来回顾这些文件的目的和交互,从而获得架构和依赖树的概览:

图 7.6:Express 服务器架构

index.ts 包含一个 start 函数,该函数启动应用程序,利用四个主要助手:

  • config.ts:管理环境变量和设置。

  • app.ts:配置 Express.js 并定义所有 API 路径,然后路由实现路径并利用包含业务逻辑的服务。服务使用模型,如 user.ts,来访问数据库。

  • api.graphql.ts:配置 GraphQL,解析器实现查询,并使用相同的解析器和突变器利用相同的服务来访问数据库。

  • document-ts:建立与数据库的连接,配置它,并在启动期间利用 user.ts 配置种子用户。

您可以看到,图顶部的组件负责启动和配置任务,包括配置 API 路径,这代表了 API 层。业务层应该包含应用程序的大部分业务逻辑,而数据访问则在 持久层 处理。

参考以下 index.ts 的实现,它展示了所有主要组件按顺序的简化版本:

**server/src/index.****ts**
...
export let server: http.Server
async function start() {
  await document.connect(config.MongoUri, config.IsProd)
  server = http.createServer(app)
  await useGraphQL(app)
  server.listen(config.Port, async () => {
    console.log(`Server listening on port ${config.Port}...`)
  })
}
start() 

注意,显示的最后一行代码 start() 是触发服务器初始化的功能调用。

现在,让我们来调查一下 Express 服务器是如何设置的。

服务器启动

app.ts 配置 Express.js,包括提供静态资源、路由和版本控制。Express.js 利用中间件函数与库或您的代码集成。中间件是在对 Express 服务器请求的生命周期中执行的函数。中间件函数可以访问请求和响应对象以及应用程序请求-响应周期中的下一个中间件函数。这种访问允许它们执行任何代码,进行更改,结束请求-响应周期,并调用堆栈中的下一个中间件。在下面的代码中,cors、logger 和 compression 是库函数,在章节的后面,我们将介绍自定义认证中间件的实现:

**server/src/app.****ts**
import api from './api'
const app = express()
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(logger('dev'))
app.use(compression())
app.use('/', express.static(path.join(__dirname, '../public'), { redirect: false }))
app.use(api)
export default app 

在前面的代码中,请注意,使用use()方法配置 Express 非常简单。首先,我们配置cors,然后是express解析器、loggercompression

接下来,使用express.static函数,我们在根路由/上提供public文件夹,这样我们就可以显示有关我们服务器的一些有用信息,如本章开头所示的图 7.1

最后,我们配置路由器,该路由器在api.ts中定义。

REST 路由和版本控制

api.ts配置 Express 路由器。请参考以下实现:

**server/src/api.****ts**
import { Router } from 'express'
import api_v1 from './v1'
import api_v2 from './v2'
const api = Router()
// Configure all routes here
api.use('/v1', api_v1)
api.use('/v2', api_v2)
export default api 

在这种情况下,我们有v1v2两个子路由。始终对实现的 API 进行版本控制是至关重要的。一旦 API 公开,简单地淘汰一个 API 以支持新版本可能会变得很棘手,有时甚至不可能。即使是微小的代码更改或 API 的细微差异也可能导致客户端崩溃。你必须仔细注意,只对你的 API 进行向后兼容的更改。

在某个时候,你可能需要完全重写端点以满足新的要求、性能和业务需求,此时,你可以简单地实现端点的v2版本,同时保持v1实现不变。这允许你以你需要的速度进行创新,同时保持你的应用程序的旧版消费者功能正常。

简而言之,你应该为创建的每个 API 进行版本控制。通过这样做,你迫使你的消费者对你的 API 的 HTTP 调用进行版本控制。随着时间的推移,你可以在不同的版本下过渡、复制和退役 API。消费者随后可以选择调用对他们有用的 API 版本。

配置路由很简单。让我们看看v2的配置,如下所示:

**server/src/v2/index.****ts**
import { Router } from 'express'
import userRouter from './routes/userRouter'
const router = Router()
// Configure all v2 routers here
router.use('/users?', userRouter)
export default router 

/users?结尾的问号意味着/user/users都将针对userRouter中实现的操作工作。这是一种避免拼写错误的同时允许开发者选择对操作有意义的复数形式的好方法。

userRouter中,你可以实现 GET、POST、PUT 和 DELETE 操作。请参考以下实现:

**server/src/v2/routes/userRouter.****ts**
const router = Router()
router.get('/', async (req: Request, res: Response) => {})
router.post('/', async (req: Request, res: Response) => {})
router.get('/:userId', async (req: Request, res: Response) => {})
router.put('/:userId', async (req: Request, res: Response) => {})
export default router 

在前面的代码中,你可以观察到路由参数的使用。你可以通过请求对象,如req.params.userId,来消费路由参数。

注意,示例代码中的所有路由都被标记为async,因为它们都会进行数据库调用,我们将等待这些调用。如果你的路由是同步的,那么你不需要async关键字。

接下来,让我们调查 GraphQL 解析器。

GraphQL 解析器

GraphQL 解析器在resolvers.ts中实现。GraphQL 服务器对查询进行广度优先遍历,并递归调用解析器以生成响应。

让我详细说明一下——当一个 GraphQL 服务器接收到一个查询时,它会逐层处理请求,从顶层字段开始,水平地穿过结构,就像在树的每一层移动之前先进行搜索,这被称为广度优先遍历。对于它遇到的每个字段,服务器将调用一个特定的函数,称为解析器,用于获取该字段的数据。如果一个字段复杂且包含嵌套子字段,该字段的解析器将依次调用其他解析器来获取每个子字段的数据。这个过程会重复进行,根据需要进入查询的层次结构,直到检索到查询的所有数据,并将其组装成与原始查询布局相匹配的结构化响应。

参考以下实现:

**server/src/graphql/resolvers.****ts**
export const resolvers = {
  Query: {
    me: () => ...,
    user: () => ...,
    users: () => ...,
  },
  Mutation: {
    login: () => ...,
    },
    createUser: () => ...,
    updateUser: () => ...,
  }, 
 a resolver function for each query and mutation implemented in the scheme. Each resolver takes in four arguments (parent, args, contextValue, info): parent can be used to access a parent resolver, args contains any input arguments passed in, contextValue stores session data useful for auth, and info contains metadata about the query itself. Next, let’s look at the type resolvers:
**server/src/graphql/resolvers.****ts**
  User: {
    id: (obj: User) => obj._id.toString(),
    role: (obj: User) => EnumValues.getNameFromValue(Role, obj.role),
    phones: (obj: User) => (obj.phones ? wrapAsArray(obj.phones) : []),
    dateOfBirth: (obj: User) => obj.dateOfBirth?.toISOString(),
  },
  Phone: {
    type: (obj: { type: string }) =>
      EnumValues.getNameFromValue(PhoneType, obj.type),
  },
  Users: {
    data: (obj: Users) => (obj.data ? wrapAsArray(obj.data) : []),
  },
} 

对于非标量类型、数组或枚举,我们可能需要提供转换,以便 GraphQL 可以适当地解包从数据库检索到的数据。好处是我们只需要为需要此类操作的对象的特定属性提供解析器。

解析器可能看起来很简单,但它们可以满足非常复杂的需求,例如,一个简单的客户端请求可能涉及多次调用服务和数据库,并将结果汇总成一个高效的响应,以便客户端可以显示它。

解析器的原子性质意味着我们只需要实现一次。接下来,让我们探索如何配置服务。

服务

我们不希望在表示我们的 API 层的路由器文件中实现我们的业务逻辑。API 层应主要包含转换数据和调用业务逻辑层。

您可以使用 Node.js 和 TypeScript 功能来实现服务。不需要复杂的依赖注入。示例应用程序实现了两个服务 - authServiceuserService

例如,在userService.ts中,您可以实现一个名为createNewUser的函数:

**server/src/services/userService.****ts**
import { IUser, User } from '../models/user'
export async function createNewUser(userData: IUser):
  Promise<User | boolean> {
  // create user
} 

createNewUser接受userData,其形状为IUser,当它完成用户创建后,返回一个User实例。然后我们可以在我们的路由器中如下使用此函数:

**server/src/v2/routes/userRouter.****ts**
import { createNewUser } from '../../services/userService'
router.post('/', async (req: Request, res: Response) => {
  const userData = req.body as IUser
  const success = await createNewUser(userData)
  if (success instanceof User) {
    res.send(success)
  } else {
    res.status(400).send({ message: 'Failed to create user.' })
  }
}) 

我们可以等待createNewUser的结果,如果成功,将创建的对象作为对 POST 请求的响应返回。

注意,尽管我们将req.body转换为IUser,但这只是一个开发时的便利功能。在运行时,消费者可能将任意数量的属性传递到主体中。粗心处理请求参数是您的代码可能被恶意利用的主要方式之一。

恭喜!现在,您已经很好地理解了我们的 Express 服务器是如何工作的。接下来,让我们看看如何连接到 MongoDB。

MongoDB ODM with DocumentTS

DocumentTS 充当一个ODM,实现了一层模型,以实现与数据库对象的丰富和可定制的交互。ODM 是关系数据库中对象关系映射器(ORM)的文档数据库等价物。想想 Hibernate 或 Entity Framework。如果您不熟悉这些概念,我建议您在继续之前进行进一步的研究。

要开始,您可以查看以下文章,MongoDB ORMs,ODMs 和库,在www.mongodb.com/developer/products/mongodb/mongodb-orms-odms-libraries

在其核心,DocumentTS 利用 MongoDB 的 Node.js 驱动程序。MongoDB 的制作者实现了这个驱动程序。它保证了最佳性能和与新 MongoDB 版本的功能一致性,而第三方库通常在支持新功能方面落后。通过使用database.getDbInstance方法,您可以直接访问原生驱动程序。否则,您将通过您实现的模型访问 Mongo。参考以下图表以获取概述:

图片

图 7.7:DocumentTS 概述

您可以在mongodb.github.io/node-mongodb-native/上了解更多关于 MongoDB 的 Node.js 驱动程序的信息。

有关 DocumentTS 的工作方式和配置细节的更多详细信息,请参阅 GitHub 上的项目 Wiki github.com/duluca/document-ts/wiki。Wiki 涵盖了连接到数据库、定义实现IDocument的模型以及配置数据的序列化和反序列化。模型允许包含计算属性,如fullName,在客户端响应中,同时排除如密码等字段。密码也被防止以明文形式保存到数据库中。

概述继续通过演示如何创建索引和使用聚合查询数据库。它为电子邮件创建了一个唯一索引,因此不能注册重复的电子邮件。一个加权文本索引有助于过滤查询结果。DocumentTS 旨在在原生 MongoDB 驱动程序之上提供一个方便且可选的层,以帮助构建完全异步的 Web 应用程序。开发者直接接触到 MongoDB 驱动程序,因此他们学习如何与数据库而不是仅仅与库一起工作。

让我们看看如何使用新的用户模型来获取数据。

实现 JWT 认证

第五章设计认证和授权中,我们讨论了实现基于 JWT 的认证机制。在 LemonMart 中,您实现了一个基础认证服务,它可以扩展为自定义认证服务。

我们将利用三个包来实现我们的实现:

  • jsonwebtoken: 用于创建和编码 JWT

  • bcryptjs: 用于在数据库中保存用户密码之前对其进行哈希和加盐,因此我们永远不会以明文形式存储用户的密码

  • uuid:一个生成的全局唯一标识符,当需要将用户的密码重置为随机值时非常有用

散列函数是一种一致可重复的单向加密方法,这意味着每次提供相同的输入时都会得到相同的输出,即使您有权访问散列值,也无法轻易地找出它存储的信息。然而,我们可以通过散列用户的输入并将其与存储的密码散列值进行比较,来验证用户是否输入了正确的密码。

认证服务在存储用户密码之前对其进行散列,并在登录时比较散列密码。createJwt函数在成功登录后生成 JWT 访问令牌。认证中间件解码 JWT 并将用户加载到响应流中,以便认证端点可以访问。

注意代码中不正确的电子邮件/密码消息的模糊性。这样做是为了防止恶意行为者利用认证系统。

对于密码散列,User模型的setPassword方法使用 bcrypt 的genSalthash函数。comparePassword方法将存储的散列密码与用户输入的散列密码进行比较。这确保密码永远不会以纯文本形式存储。

登录 API 端点通过电子邮件查找用户,调用comparePassword来验证密码,成功后调用createJwt生成包含电子邮件、角色等用户详情的已签名 JWT,并将 JWT 作为accessToken返回给客户端:

// Example of hashing and salting password
user.setPassword = async (password) => {
  const salt = await bcrypt.genSalt(10);
  return await bcrypt.hash(password, salt); 
} 

认证中间件解码 JWT,通过编码的id查找用户,并将用户注入到res.locals.currentUser中。像/me这样的认证端点可以方便地访问用户信息。它还通过检查如requiredRole之类的选项来处理基于角色的访问:

// Example of JWT-based login
router.post('/login', async (req, res) => {
  const user = await User.findByEmail(req.body.email);
  if (user && user.comparePassword(req.body.password)) {
    const accessToken = createJwt(user);
    return res.send({accessToken});
  }
  return res.status(401).send('Invalid credentials');
}) 

当通过电子邮件检索用户时,请记住电子邮件是不区分大小写的,因此您应该始终将输入转换为小写。您可以通过验证电子邮件并删除任何空白、脚本标签或甚至恶意 Unicode 字符来进一步改进此实现。考虑使用express-validatorexpress-sanitizer等库。

认证中间件

authenticate函数是我们可以在 API 实现中使用的中间件,以确保只有具有适当权限的认证用户可以访问端点。请记住,真正的安全性是在您的后端实现中实现的,而这个authenticate函数是您的守门人。

authenticate接受一个可空的options对象,使用requiredRole属性验证当前用户的角色,因此如果 API 配置如下,则只有经理可以访问该 API:

authenticate(**{** **requiredRole****:** **Role****.****Manager** **}**) 

在某些情况下,我们希望用户能够更新自己的记录,同时也允许经理更新其他所有人的记录。在这种情况下,我们利用permitIfSelf属性,如下所示:

authenticate({
    requiredRole: Role.Manager,
    **permitIfSelf****: {**
      **idGetter****:** **(****req: Request****) =>** **req.****body****.****_id****,**
      **requiredRoleCanOverride****:** **true****,**
    **},**
  }), 

在这种情况下,如果更新记录的 _id 与当前用户 _id 匹配,则用户可以更新自己的记录。由于 requiredRoleCanOverride 设置为 true,经理可以更新任何记录。如果设置为 false,则不允许这样做。通过混合和匹配这些属性,你可以覆盖大多数门控需求。

注意,idGetter 是一个函数委托,这样你就可以指定在 authenticate 中间件执行时 _id 属性应该如何访问。

查看以下简化版 authenticate 中间件及其使用的示例实现:

完整实现可以在 server/src/services/auth.service.ts 中找到。

// Authenticate middleware
function authenticate(options) {
  return async (req, res, next) => {
    const user = await decodeAndFindUser(req.headers.authorization);
    if (user) {
      // Check role if required
      if (options.requiredRole && user.role !== options.requiredRole) {
        return res.status(403).send("Forbidden");
      }

      // Attach user to response 
      res.locals.user = user;

      return next();
    } else {
      return res.status(401).send('Unauthenticated');
    }
  }
}
// Usage in RESTful route
router.get('/me', authenticate(), (req, res) 
  => res.send(res.locals.user)
) 

authenticate 方法作为 Express.js 中间件实现。它可以读取请求头中的授权令牌,验证提供的 JWT 的有效性,加载当前用户,并将其注入到响应流中,以便认证的 API 端点可以方便地访问当前用户的信息。这在上面的 me API 中显示。如果成功,中间件调用 next() 函数将控制权交还给 Express。如果失败,则无法调用 API。

注意,authenticateHelper 返回有用的错误消息,所以如果用户尝试执行他们无权执行的操作,他们不会感到困惑。

在 GraphQL 中,认证和授权是分开处理的。在 Express.js 层级上,我们将 authenticate 中间件应用于 /graphql 路由。然而,为了使探索、内省和登录函数正常工作,我们必须对规则进行例外处理。请参阅下面的代码,它实现了这种逻辑:

// GraphQL authentication
app.use('/graphql', authenticate({ 
    authOverridingOperations: ['Login'] 
  })
)
// Usage in GraphQL resolver
me: (parent, args, contextValue) => authorize(contextValue), 

查看 server/src/graphql/resolvers.ts 以了解认证实现的完整示例。

authOverridingOperations 属性通知 authenticate 允许调用内省和 Login 函数。现在对其他 GraphQL 函数的所有调用都将使用在解析器中可用的认证上下文进行认证。在解析器中,我们可以使用 authorize 方法(位于 server/src/graphql/helpers.ts)来检查请求者是否可以查看他们试图访问的资源。contextValue 存储会话上下文,类似于 Express 中的 res.local

接下来,让我们实现两个自定义认证提供者,一个用于 REST,另一个用于 GraphQL。

自定义服务器认证提供者

现在你已经理解了我们服务器中的认证实现,我们可以在 LemonMart 中实现一个自定义认证提供者,如第六章实现基于角色的导航所述:

你必须在你的 Angular 应用中实现这个自定义认证提供者。

本节的代码示例位于 lemon-mart-appappweb-app 文件夹中的 projects/stage10 文件夹。

  1. 首先,在 environment.ts 中创建一个 baseUrl 变量,以便我们可以连接到你的服务器。

  2. environment.tsenvironment.prod.ts 中实现一个 baseUrl 变量。

  3. 此外,选择 authModeAuthMode.CustomServer

    **web-app/src/environments/environment.****ts**
    **web-app/src/environments/environment.****prod****.****ts**
    export const environment = {
      ...
      baseUrl: 'http://localhost:3000',
      authMode: AuthMode.CustomServer, 
    
  4. 安装一个辅助库以编程方式访问 TypeScript 枚举值:

    $ npm i ts-enum-util 
    
  5. 如下所示,使用 HttpClient 实现基于 RESTful 的自定义身份验证提供者:

    **web-app/src/app/auth/auth.****custom****.****service****.****ts**
    import { $enum } from 'ts-enum-util'
    ...
    @Injectable(@Injectable({ providedIn: 'root' }))
    export class CustomAuthService extends AuthService {
      private httpClient: HttpClient = inject(HttpClient)
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        return this.httpClient.post<IServerAuthResponse>
          (`${environment.baseUrl}/v1/auth/login`, {
            email,
            password,
          })
          .pipe(first())
      }
      protected transformJwtToken(token: IJwtToken): IAuthStatus {
        return {
          isAuthenticated: token.email ? true : false,
          userId: token.sub,
          userRole: $enum(Role)
            .asValueOrDefault(token.role, Role.None),
          userEmail: token.email,
          userPicture: token.picture,
        } as IAuthStatus
      }
      protected getCurrentUser(): Observable<User> {
        return this.httpClient
          .get<IUser>(`${environment.baseUrl}/v1/auth/me`)
            .pipe(
            first(),
            map((user) => User.Build(user)),
              catchError(transformError)
            )
      }
    } 
    
  6. authProvider 方法调用我们的 /v1/auth/login 方法,而 getCurrentUser 调用 /v1/auth/me 来检索当前用户。

    确保对 login 方法的调用始终发生在 HTTPS 上。否则,你将在开放的互联网上发送用户凭据。这很容易让公共 Wi-Fi 网络上的窃听者窃取用户凭据。

  7. 如下所示,使用 Apollo Client 实现基于 GraphQL 的自定义身份验证提供者:

    **web-app/src/app/auth/auth.****graphql****.****custom****.****service****.****ts**
    import { GET_ME, LOGIN } from './auth.graphql.queries'
    ...
    @Injectable({ providedIn: 'root' })
    export class CustomGraphQLAuthService extends AuthService {
      private apollo: Apollo = inject(Apollo)
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        return this.apollo
          .mutate<{ login: IServerAuthResponse }>({
            mutation: LOGIN,
            variables: {
              email,
              password,
            },
          })
          .pipe(
            first(),
            map((result) => 
              result.data?.login as IServerAuthResponse
            )
          )
        }
      protected transformJwtToken(token: IJwtToken): IAuthStatus {
        return {
          isAuthenticated: token.email ? true : false,
          userId: token.sub,
          userRole: $enum(Role).asValueOrDefault(
            token.role,
            Role.None
          ),
          userEmail: token.email,
          userPicture: token.picture,
        } as IAuthStatus
      }
      protected getCurrentUser(): Observable<User> {
        return this.apollo
          .watchQuery<{ me: IUser }>({
            query: GET_ME,
          })
          .valueChanges.pipe(
            first(),
            map((result) => User.Build(result.data.me))
          )
      }
    } 
    

    注意,LOGIN 变异和 Me 查询是在 auth.graphql.queries.ts 中实现的。否则,它们会占用太多空间,使得服务代码难以阅读。

  8. 更新 authFactory 以返回 AuthMode.CustomServer 选项的新提供者:

    **web-app/src/app/auth/auth.****factory****.****ts**
    export function authFactory() {
      ...
      case AuthMode.CustomServer:
        return new CustomAuthService()
      case AuthMode.CustomGraphQL:
        return new CustomGraphQLAuthService()
    } 
    
  9. 启动你的 Web 应用程序以确保一切正常工作。

恭喜!你现在已经掌握了代码在整个软件栈中的工作方式,从数据库到前端和后端。

摘要

在本章中,我们介绍了全栈架构。你学习了如何构建最小化的 MEAN 栈。你现在知道如何为全栈应用程序创建 monorepo 并配置 TypeScript 的 Node.js 服务器。你学习了 monorepos、容器化 Node.js 服务器以及使用 Docker Compose 声明性地定义基础设施。通过使用 Docker Compose 与 CircleCI,我们看到了如何在 CI 环境中验证你的基础设施。

你学习了如何使用 Apollo 和 OpenAPI 以及 GraphQL 设计 RESTful API,设置 Express.js 应用程序,并配置它以便为你的 API 生成交互式文档。你了解了使用 DocumentTS 与 MongoDB 一起使用的优势。

然后,你实现了一个基于 JWT 的身份验证服务,使用 authenticate 中间件来保护 API 端点并允许 RBAC。最后,你在 Angular 中实现了两个自定义身份验证提供者。对于 REST,我们使用了 HttpClient,而对于 GraphQL,我们使用了 Apollo Client。

接下来的两章将探索 Angular 的配方来创建表单和数据表。在第八章 配方 – 可重用性、表单和缓存 和第九章 配方 – 主/详细信息、数据表和 NgRx 中,我们将通过坚持解耦组件架构、明智地选择创建用户控件和组件以及最大化代码重用,结合各种 TypeScript、RxJS、NgRx 和 Angular 编码技术来整合一切。

在本书的剩余部分,你将需要确保你的 LemonMart 服务器和 MongoDB 实例运行正常,以便在实现表单和表格时验证它们的功能是否正确。

练习

你使用authenticate中间件来保护你的端点。你配置了 Postman 发送有效的令牌,以便你可以与受保护的端点通信。作为一个练习,尝试移除authenticate中间件,并使用有效的令牌和无令牌调用相同的端点。重新添加中间件,然后再次尝试相同的事情。观察服务器返回的不同响应。

进一步阅读

问题

回答以下问题以确保你已理解本章的关键概念,无需使用谷歌搜索。你知道你是否答对了所有问题吗?访问angularforenterprise.com/self-assessment获取更多信息:

  1. 构成优秀开发者体验的主要组件有哪些?

  2. .env文件有什么用途?

  3. authenticate中间件的目的是什么?

  4. Docker Compose 与使用 Dockerfile 有何不同?

  5. ODM 是什么?它与 ORM 有何不同?

  6. 中间件是什么?

  7. OpenAPI 规范有哪些用途?

  8. 你会如何重构userRouter.ts中的/v2/users/{id} PUT端点的代码,以便代码可重用?

  9. REST 和 GraphQL 之间有哪些主要区别?

  10. OpenAPI 和 GraphQL 模式之间有哪些相似之处?

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

packt.link/AngularEnterpise3e

二维码