0

我有多个 Angular 应用程序,它们都引用相同的 GraphQL API(使用 Apollo)并决定将 API 服务层迁移到共享库。作为该服务层的一部分,我正在导出一个名为“ApiValidator”的验证函数,用于触发表单中的错误状态。

它的工作方式是每当 API 遇到错误时,响应都会将其注册到 ErrorService 单例中的可观察对象。ApiValidator 函数返回一个承诺检查 ErrorService 是否有任何具有匹配“字段”属性的错误,如果是则返回错误。

当一切都在应用程序级别时,这非常有效,但是在迁移到库之后,验证器每次都会创建一个新的单例实例。这是因为我使用 Injector.create 和“useClass”来获取对验证器中服务的引用。但是,如果没有循环依赖,我无法让“useExisting”工作,并且在尝试通过包装服务中的构造函数使用 DI 时也遇到了同样的问题。

我不确定如何有效地设置它以提供关于 stackblitz 的工作示例或考虑到它跨私有库和应用程序的东西 - 如果有帮助,很乐意将代码发布在那里。现在,我已经在下面发布了所有相关部分。

非常感谢您提前提供任何见解或帮助!

图书馆代码

错误服务

@Injectable({
  providedIn: 'root'
})
export class ErrorService {
  /**
   * An observable list of response errors returned from the graphql API
   */
  private errors: BehaviorSubject<GraphQLResponseError[]> = new BehaviorSubject<GraphQLResponseError[]>([]);

  /**
   * Get any errors on the page
   * @return An observable of the current errors on the page
   */
  public getErrors(): Observable<GraphQLResponseError[]> {
    return this.errors;
  }

  /**
   * Get only field message errors for forms
   * @return An observable of current field errors on the page
   */
  public getFieldErrors(): Observable<GraphQLResponseError[]> {
      return this.errors.pipe(map((error: GraphQLResponseError[]) => {
          return error.filter((err: GraphQLResponseError) => err.context === 'field');
      }));
  }

  /**
   * Get only page message errors
   * @return An observable of current page errors on the page
   */
  public getPageErrors(): Observable<GraphQLResponseError[]> {
      return this.errors.pipe(map((error: GraphQLResponseError[]) => {
          return error.filter((err: GraphQLResponseError) => err.context === 'page');
      }));
  }

  /**
   * Records a response error in the list of errors
   * @param error The error to add to the page
   */
  public recordError(error: GraphQLResponseError): void {
      this.errors.pipe(take(1)).subscribe(errors => {
          if (!errors.includes(error)) {
              errors.push(error);
              this.errors.next(errors);
          }
      });
  }
}

ApiValidator

import { Injector } from '@angular/core';
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { GraphQLResponseError } from '../interfaces/graphql-response-error.interface';
import { take, map } from 'rxjs/operators';
import { ErrorService } from '../services/error.service';

/**
 * Validator which triggers validations based on field errors recorded in the page service
 * @param control The form control which should be validated against this validator
 * @return A promise with the errors associated with the form or null if there are none
 */
export function ApiValidator(control: AbstractControl): Promise<ValidationErrors | null> {
    const injector = Injector.create({
        providers: [{ provide: ErrorService, useClass: ErrorService, deps: [] }]
    });
    const errorService = injector.get(ErrorService);
    // get the name of the control to compare to any errors in the service
    const controlName = (control.parent) ? Object.keys(control.parent.controls).find(name => control === control.parent.controls[name]) : null;

    // return any errors that exist for the current control, or null if none do
    return errorService.getFieldErrors().pipe(take(1), map((errors: GraphQLResponseError[]) => {
        if (errors && errors.length > 0) {
            const fieldErrors = errors.filter((error: GraphQLResponseError) => error.field === controlName);
            return (fieldErrors.length > 0) ? { api: fieldErrors.map(error => error.message).join()} : null;
        }
        return null;
    })).toPromise();
}

接口模块

import { HttpClientModule } from '@angular/common/http';
import { CommonModule, DatePipe } from '@angular/common';
import { NgModule, Optional, SkipSelf, ModuleWithProviders } from '@angular/core';
import { Apollo, ApolloBoost, ApolloBoostModule, ApolloModule } from 'apollo-angular-boost';
import { GraphQLService } from './services/graphql.service';
import { ApiValidator } from './validators/api.validator';


@NgModule({
  declarations: [
  ],
  imports: [
    HttpClientModule,
    CommonModule,
    ApolloBoostModule,
    ApolloModule
  ],
  providers: [
    DatePipe,
    {
      provide: GraphQLService,
      useClass: GraphQLService,
      deps: [Apollo, ApolloBoost, DatePipe]
    }
  ]
})
export class ApiModule {
  constructor(@Optional() @SkipSelf() parentModule?: ApiModule) {
    if (parentModule) {
      throw new Error(
        'ApiModule is already loaded. Import it in the AppModule only');
    }
  }
}

GraphQLService(相关部分)

import { Injectable, Injector, Inject } from '@angular/core';
import { ApolloBoost, Apollo, gql, WatchQueryFetchPolicy } from 'apollo-angular-boost';
import { Observable, BehaviorSubject } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { GraphQLResponse } from '../interfaces/graphql-response.interface';
import { GraphQLRefetchQuery } from '../interfaces/graphql-refetch-query.interface';
import { DatePipe } from '@angular/common';
import { GraphQLResponseError } from '../interfaces/graphql-response-error.interface';
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { ErrorService } from './error.service';


@Injectable({
  providedIn: 'root'
})
export class GraphQLService {
  /**
   * @var API_DATE_FORMAT The format to use for representing dates
   */
  protected readonly API_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss';

  /**
   * @ignore
   */
  constructor(
    protected apollo: Apollo,
    protected apolloBoost: ApolloBoost,
    protected datePipe: DatePipe,
    protected errorService: ErrorService
  ) {
  }

  /**
   * Initializes the connection with the graphql API
   * @param url The graphql API endpoint
   * @param token The authorization token to use for requests
   */
  public connect(url: string, token: string = null): void {
    // set environment variables to app context
    this.apolloBoost.create({
      uri: url,
      request: async (operation) => {
        if (token !== null) {
          operation.setContext({
            headers: {
              authorization: token
            }
          });
        }
      },
      onError: ({ graphQLErrors, networkError }) => {
          // if the gql request returned errors, register the errors with the page
          if (graphQLErrors) {
              graphQLErrors.forEach(error => {
                  // if the error has an extensions field, consider it a field-level error
                  if (error.hasOwnProperty('extensions') && error.extensions.hasOwnProperty('field')) {
                      this.errorService.recordError({
                          context: 'field',
                          message: error.message,
                          field: error.extensions.field
                      });
                  }
                  // else, consider a page level error
                  else {
                      this.errorService.recordError({
                          context: 'page',
                          message: error.message
                      });
                  }
              });
          }
          // if there were network errors, register those with the page
          // note, it doesn't seem to work correctly with true network errors
          // and we catch these in the api.interceptor for now
          if (networkError) {
              this.errorService.recordError({
                  context: 'page',
                  message: networkError.message
              });
          }
      }
    });
  }
}

申请代码

应用模块

import { HttpClientModule, HttpClient, HTTP_INTERCEPTORS } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { NgModule, APP_INITIALIZER, Injector } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterModule } from '@angular/router';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { CookieService } from 'ngx-cookie-service';
import { environment } from '../environments/environment';
import { GraphQLService, ApiModule } from '@angular/library';
import { initializeAuthentication, AppContextService, AuthInterceptor } from '@application/core';
import { SharedModule } from '@application/shared';
import { LayoutModule } from './layout/layout.module';
import { routes } from './app.routing';
import { AppComponent } from './app.component';
import { DashboardComponent } from './pages/dashboard/dashboard.component';

@NgModule({
  declarations: [
    AppComponent,
    DashboardComponent
  ],
  imports: [
    RouterModule.forRoot(routes),
    BrowserModule,
    BrowserAnimationsModule,
    HttpClientModule,
    CommonModule,
    SharedModule,
    LayoutModule,
    MatTooltipModule,
    ApiModule
  ],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: (appContextService: AppContextService) => function() { appContextService.setEnvironmentVariables(environment); },
      deps: [AppContextService],
      multi: true
    },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true
    },
    {
      provide: APP_INITIALIZER,
      useFactory: initializeAuthentication,
      multi: true,
      deps: [HttpClient, AppContextService, CookieService]
    },
    {
      provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
      useValue: {
        appearance: 'outline'
      }
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
  constructor(private gqlService: GraphQLService, private cookieService: CookieService) {
    // if using authorization set token
    const token = (environment.useAuthorization) ? this.cookieService.get('auth') : null;

    // initialize connection with api
    this.gqlService.connect(environment.apiUrl, token);
  }
}

UserForm(相关部分)

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormBuilder, FormArray } from '@angular/forms';
import { take, finalize, catchError } from 'rxjs/operators';
import { ToastrService } from 'ngx-toastr';
import { User, GraphQLResponse, UserService, ApiValidator } from '@application/library';
import { PageMode, PageService } from '@application/core';

@Component({
    selector: 'sm-user-form',
    templateUrl: './user-form.component.html',
    styleUrls: ['./user-form.component.scss']
})
export class UserFormComponent implements OnInit {
    /**
     * @ignore
     */
    constructor(
        private fb: FormBuilder,
        private activatedRoute: ActivatedRoute,
        private router: Router,
        public pageService: PageService,
        private userService: UserService,
        private toastService: ToastrService
    ) {
        // define form structure
        this.form = this.fb.group({
            email: ['', null, ApiValidator],
            password: ['', null, ApiValidator],
            prefix: ['', null, ApiValidator],
            firstName: ['', null, ApiValidator],
            middleName: ['', null, ApiValidator],
            lastName: ['', null, ApiValidator]
        });
    }
}

UserFormTemplate(相关部分)

<div fxLayout="row">
        <mat-form-field fxFlex="10">
            <mat-label>Prefix</mat-label>
            <input matInput formControlName="prefix">
            <mat-error *ngIf="form.get('prefix').hasError('api')">{{form.get('prefix').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="30">
            <mat-label>First Name</mat-label>
            <input matInput formControlName="firstName">
            <mat-error *ngIf="form.get('firstName').hasError('api')">{{form.get('firstName').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="20">
            <mat-label>Middle Name</mat-label>
            <input matInput formControlName="middleName">
            <mat-error *ngIf="form.get('middleName').hasError('api')">{{form.get('middleName').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="30">
            <mat-label>Last Name</mat-label>
            <input matInput formControlName="lastName">
            <mat-error *ngIf="form.get('lastName').hasError('api')">{{form.get('lastName').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="10">
            <mat-label>Suffix</mat-label>
            <input matInput formControlName="suffix">
            <mat-error *ngIf="form.get('suffix').hasError('api')">{{form.get('suffix').getError('api')}}</mat-error>
        </mat-form-field>
    </div>
4

1 回答 1

0

我终于能够解决这个问题。使用提供者配置、注入器元数据等的方法组合无法阻止验证器创建 ErrorService 的新实例的问题。

主要问题是,由于我使用的是 Injector.create(),它正在创建注入器本身的一个新实例——这意味着该注入器中不存在对单例的任何引用,每次都强制创建一个新实例。但是,我无法通过提供程序/等成功地将服务放入验证器功能。

所以,为了解决这个问题,我使用了服务定位器模式并从模块创建了对注入器的静态引用,然后直接使用该静态引用来访问单例服务(最初在几年前发现这种方法手动注入服务)。

首先,我创建了一个新的实用程序类:

import {Injector} from '@angular/core';

export class ServiceLocator {
    static injector: Injector = null;
}

然后,在 ApiModule 的构造函数中:

  constructor(private injector: Injector, @Optional() @SkipSelf() parentModule?: ApiModule) {
    if (parentModule) {
      throw new Error(
        'ApiModule is already loaded. Import it in the AppModule only');
    }

    // create a static reference to the module injector that can be used to reliably retrieve singleton classes throughout the library
    ServiceLocator.injector = this.injector;
  }

在 ApiValidator 中,替换这部分:

    const injector = Injector.create({
        providers: [{ provide: ErrorService, useClass: ErrorService, deps: [] }]
    });
    const errorService = injector.get(ErrorService);

参考静态注射器:

    const errorService = ServiceLocator.injector.get(ErrorService);

最后,更新了 GraphQLService 以使用相同的静态注入器:

  constructor(
    protected apollo: Apollo,
    protected apolloBoost: ApolloBoost,
    protected datePipe: DatePipe
  ) {
    this.errorService = ServiceLocator.injector.get(ErrorService);
  }

...现在所有东西都共享同一个 ErrorService 实例,让验证器正确引用由 graphqlservice(或其他任何东西)触发的错误。

我确信可能有一种更优雅或更“正确”的方式来做到这一点 - 所以如果有更好的方式,我当然很感激建议。否则,我希望有人能发现这很有用。

于 2020-12-16T13:46:58.493 回答