angular 错误处理
Approximately a year ago, I have implemented the first e2e tests on a project. It was a rather big application using JAVA SpringBoot on the back-end and Angular on the front-end. We used Protractor as a testing tool, which uses Selenium. In the front-end code there was a service, which had an error handler method. When that method was called, a modal dialog popped up and the user could see the details of the errors and the stack-trace.
大约一年前,我已经在一个项目上实施了第一批e2e测试。 这是一个相当大的应用程序,在后端使用JAVA SpringBoot,在前端使用Angular。 我们使用量角器作为测试工具,它使用了Selenium。 在前端代码中有一个服务,该服务具有错误处理程序方法。 调用该方法时,将弹出一个模式对话框,用户可以看到错误的详细信息和堆栈跟踪。
The problem was that while it has tracked every error that happened on the back-end, the front-end failed silently. TypeErrors, ReferenceErrors and other uncaught exceptions were logged only to the console. When something went wrong during e2e test runs the screenshot, which was taken when the test step has failed, has shown absolutely nothing. Have fun debugging that!
问题在于,尽管它跟踪了后端发生的每个错误,但前端却静默地失败了。 TypeErrors , ReferenceErrors和其他未捕获的异常仅记录到控制台。 当e2e测试期间出现问题时,将运行测试步骤失败时所截取的屏幕截图,该结果完全没有显示。 祝您调试愉快!
Luckily Angular has a built-in way of handling errors and it is extremely easy to use. We just have to create our own service, which implements Angular's ErrorHandler interface:
幸运的是,Angular具有内置的错误处理方式,非常易于使用。 我们只需要创建自己的服务即可实现Angular的ErrorHandler接口:
import { ErrorHandler, Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler{
constructor() {}
handleError(error: any) {
// Implement your own way of handling errors
}
}
While we could easily provide our service in our AppModule, it might be a good idea to provide this service in a separate module. This way we could create our own library and use it in our future projects as well:
尽管我们可以在AppModule中轻松提供服务,但最好在单独的模块中提供此服务。 这样,我们可以创建自己的库,并在以后的项目中使用它:
// ERROR HANDLER MODULE
import {ErrorHandler, ModuleWithProviders, NgModule} from '@angular/core';
import {ErrorHandlerComponent} from './components/error-handler.component';
import {FullscreenOverlayContainer, OverlayContainer, OverlayModule} from '@angular/cdk/overlay';
import {ErrorHandlerService} from './error-handler.service';
import {A11yModule} from '@angular/cdk/a11y';
@NgModule({
declarations: [ErrorHandlerComponent],
imports: [CommonModule, OverlayModule, A11yModule],
entryComponents: [ErrorHandlerComponent]
})
export class ErrorHandlerModule {
public static forRoot(): ModuleWithProviders {
return {
ngModule: ErrorHandlerModule,
providers: [
{provide: ErrorHandler, useClass: ErrorHandlerService},
{provide: OverlayContainer, useClass: FullscreenOverlayContainer},
]
};
}
}
We used the Angular CLI for generating the ErrorHandlerModule, so we already have a component generated, which can be our modal dialog's content. In order for us to be able to put it inside an Angular CDK overlay, it needs to be an entryComponent. That is why we have put it into the ErrorHandlerModule's entryComponents array.
我们使用Angular CLI生成了ErrorHandlerModule ,因此我们已经生成了一个组件,该组件可以作为模式对话框的内容。 为了使我们能够将其放入Angular CDK叠加层中,它必须是entryComponent。 这就是为什么我们将其放入ErrorHandlerModule的entryComponents数组中的原因。
We also added some imports. OverlayModule and A11yModule comes from the CDK module. They are needed for creating our overlay and to trap focus when our error dialog is opened. As you can see, we provide OverlayContainer using the FullscreenOverlayContainer class because if an error occurs, we want to restrict our users' interactions to our error modal. If we don't have a fullscreen backdrop, the users might be able to interact with the application and cause further errors. Let's add our newly created module to our AppModule:
我们还添加了一些进口。 OverlayModule和A11yModule来自CDK模块。 当我们打开错误对话框时,它们是创建覆盖图和捕获焦点所必需的。 如您所见,我们使用FullscreenOverlayContainer类提供OverlayContainer ,因为如果发生错误,我们希望将用户的交互限制为我们的错误模式。 如果我们没有全屏背景,则用户可能能够与该应用程序进行交互并导致进一步的错误。 让我们将新创建的模块添加到AppModule中 :
// APP MODULE
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {MainComponent} from './main/main.component';
import {ErrorHandlerModule} from '@btapai/ng-error-handler';
import {HttpClientModule} from '@angular/common/http';
@NgModule({
declarations: [ AppComponent, MainComponent ],
imports: [
BrowserModule,
HttpClientModule,
ErrorHandlerModule.forRoot(),
AppRoutingModule,
],
bootstrap: [AppComponent]
})
export class AppModule {
}
Now that we have our `ErrorHandlerService` in place, we can start implementing the logic. We are going to create a modal dialog, which displays the error in a clean, readable way. This dialog will have an overlay/backdrop and it will be dynamically placed into the DOM with the help of the Angular CDK. Let's install it:
现在我们已经有了ErrorHandlerService,我们可以开始实现逻辑了。 我们将创建一个模式对话框,以清晰易读的方式显示错误。 此对话框将有一个叠加层/背景,并将在Angular CDK的帮助下将其动态放置到DOM中。 让我们安装它:
npm install @angular/cdk --save
According to the documentation, the Overlay component needs some pre-built css files. Now if we would use Angular Material in our project it wouldn't be necessary, but that is not always the case. Let's import the overlay css in our styles.css file. Note, that if you already use Angular Material in your app, you don't need to import this css.
根据文档 , Overlay组件需要一些预构建CSS文件。 现在,如果我们在项目中使用Angular Material,则没有必要,但并非总是如此。 让我们在styles.css文件中导入叠加层CSS。 请注意,如果您已经在应用程序中使用Angular Material,则无需导入此CSS。
@import '~@angular/cdk/overlay-prebuilt.css';
Let's use our handleError method to create our modal dialog. It is important to know, that the ErrorHandler service is part of the application initialisation phase of Angular. In order to avoid a rather nasty cyclic dependency error, we use the injector as its only constructor parameter. We use Angular's dependency injection system when the actual method is called. Let's import the overlay from the CDK and attach our ErrorHandlerComponent into the DOM:
让我们使用handleError方法创建模式对话框。 重要的是要知道, ErrorHandler服务是Angular应用程序初始化阶段的一部分。 为了避免出现非常讨厌的循环依赖性错误 ,我们将注入器用作其唯一的构造函数参数。 当调用实际方法时,我们使用Angular的依赖注入系统。 让我们从CDK导入叠加层,然后将我们的ErrorHandlerComponent附加到DOM中:
// ... imports
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
constructor(private injector: Injector) {}
handleError(error: any) {
const overlay: Overlay = this.injector.get(Overlay);
const overlayRef: OverlayRef = overlay.create();
const ErrorHandlerPortal: ComponentPortal = new ComponentPortal(ErrorHandlerComponent);
const compRef: ComponentRef = overlayRef.attach(ErrorHandlerPortal);
}
}
Let's turn our attention towards our error handler modal. A pretty simple working solution would be displaying the error message and the stacktrace. Let's also add a 'dismiss' button to the bottom.
让我们将注意力转向错误处理程序模式。 一个非常简单的工作解决方案是显示错误消息和stacktrace。 我们还要在底部添加一个“关闭”按钮。
// imports
export const ERROR_INJECTOR_TOKEN: InjectionToken = new InjectionToken('ErrorInjectorToken');
@Component({
selector: 'btp-error-handler',
// TODO: template will be implemented later
template: `${error.message}
`
styleUrls: ['./error-handler.component.css'],
})
export class ErrorHandlerComponent {
private isVisible = new Subject();
dismiss$: Observable<{}> = this.isVisible.asObservable();
constructor(@Inject(ERROR_INJECTOR_TOKEN) public error) {
}
dismiss() {
this.isVisible.next();
this.isVisible.complete();
}
}
As you can see, the component itself is pretty simple. We are going to use two rather important directives in the template, to make the dialog accessible. The first one is the cdkTrapFocus which will trap the focus when the dialog is rendered. This means that the user cannot focus elements behind our modal dialog. The second directive is the cdkTrapFocusAutoCapture which will automatically focus the first focusable element inside our focus trap. Also, it will automatically restore the focus to the previously focused element, when our dialog is closed.
如您所见,组件本身非常简单。 我们将在模板中使用两个相当重要的指令,以使对话框可访问。 第一个是cdkTrapFocus ,它将在呈现对话框时捕获焦点。 这意味着用户无法将元素集中在我们的模式对话框后面。 第二个指令是cdkTrapFocusAutoCapture ,它将自动将焦点陷阱中的第一个可聚焦元素聚焦。 同样,当对话框关闭时,它将自动将焦点恢复到以前的焦点元素。
In order to be able to display the error's properties, we need to inject it using the constructor. For that, we need our own injectionToken. We also created a rather simple logic for emitting a dismiss event using a subject and the dismiss$ property. Let's connect this with our handleError method in our service and do some refactoring.
为了能够显示错误的属性,我们需要使用构造函数将其注入。 为此,我们需要自己的injectionToken 。 我们还创建了一个相当简单的逻辑,用于使用主题和dismiss $属性来发出dismiss事件。 让我们将其与服务中的handleError方法连接起来,并进行一些重构。
// imports
export const DEFAULT_OVERLAY_CONFIG: OverlayConfig = {
hasBackdrop: true,
};
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
private overlay: Overlay;
constructor(private injector: Injector) {
this.overlay = this.injector.get(Overlay);
}
handleError(error: any): void {
const overlayRef = this.overlay.create(DEFAULT_OVERLAY_CONFIG);
this.attachPortal(overlayRef, error).subscribe(() => {
overlayRef.dispose();
});
}
private attachPortal(overlayRef: OverlayRef, error: any): Observable<{}> {
const ErrorHandlerPortal: ComponentPortal = new ComponentPortal(
ErrorHandlerComponent,
null,
this.createInjector(error)
);
const compRef: ComponentRef = overlayRef.attach(ErrorHandlerPortal);
return compRef.instance.dismiss$;
}
private createInjector(error: any): PortalInjector {
const injectorTokens = new WeakMap([
[ERROR_INJECTOR_TOKEN, error]
]);
return new PortalInjector(this.injector, injectorTokens);
}
}
Let's focus on providing the error as an injected parameter first. As you can see, the ComponentPortal class expects one must-have parameter, which is the component itself. The second parameter is a ViewContainerRef which would have an effect of the component's logical place of the component tree. The third parameter is our createInejctor method. As you can see it returns a new PortalInjector instance. Let's take a quick look at its underlying implementation:
让我们集中精力首先提供作为注入参数的错误。 如您所见, ComponentPortal类期望一个必备参数,即组件本身。 第二个参数是ViewContainerRef ,它将影响组件在组件树中的逻辑位置。 第三个参数是我们的createInejctor方法。 如您所见,它返回一个新的PortalInjector实例。 让我们快速看一下其基本实现:
export class PortalInjector implements Injector {
constructor(
private _parentInjector: Injector,
private _customTokens: WeakMap) { }
get(token: any, notFoundValue?: any): any {
const value = this._customTokens.get(token);
if (typeof value !== 'undefined') {
return value;
}
return this._parentInjector.get(token, notFoundValue);
}
}
As you can see, it expects an Injector as a first parameter and a WeakMap for custom tokens. We did exactly that using our ERROR_INJECTOR_TOKEN which is associated with our error itself. The created PortalInjector is used for the proper instantiation of our ErrorHandlerComponent, it will make sure that the error itself will be present in the component.
如您所见,它期望将Injector作为第一个参数,并将WeakMap作为自定义标记。 我们正是使用与错误本身相关的ERROR_INJECTOR_TOKEN做到了这一点。 创建的PortalInjector用于ErrorHandlerComponent的正确实例化,它将确保错误本身将出现在组件中。
At last, our attachPortal method returns the recently instantiated component's dismiss$ property. We subscribe to it, and when it changes we call the .dispose() on our overlayRef. And our error modal dialog is dismissed. Note, that we also call complete on our subject inside the component, therefore, we don't need to unsubscribe from it.
最后,我们的attachPortal方法返回最近实例化的组件的dismiss $属性。 我们订阅它,当它改变了我们对我们的overlayRef调用.dispose()。 并且我们的错误模式对话框被关闭了。 请注意,我们还对组件内部的主题进行了调用,因此,我们不需要取消订阅。
Now, this is excellent for errors that are thrown when there's an issue in the clinet side code. But we are creating web applications and we use API endpoints. So what happens when a REST endpint gives back an error?
现在,这对于在clinet端代码中存在问题时引发的错误非常有用。 但是我们正在创建Web应用程序,并且使用API端点。 那么,当REST endpint返回错误时会发生什么呢?
We can handle every error in its own service, but do we really want to? If everything is alright errors won't be thrown. If there are specific requirements, for example to handle 418 status code with a flying unicorn you could implement its handler in its service. But when we face rather common errors, like 404 or 503 we might want to display that in this same error dialog.
我们可以处理自己服务中的每个错误,但是我们真的要这样做吗? 如果一切正常,将不会引发错误。 如果有特定要求,例如处理418状态代码 使用飞行的独角兽,您可以在服务中实现其处理程序。 但是,当我们遇到相当常见的错误(例如404或503)时,我们可能希望在同一错误对话框中显示该错误。
Let's just quickly gather what happens when an HttpErrorResponse is thrown. It is going to happen async, so probably we are going to face some change detection issues. This error type has different properties than a simple error, therefore, we might need a sanitiser method. Now let's get into it by creating a rather simple interface for the SanitisedError:
让我们快速地收集抛出HttpErrorResponse时发生的情况。 这将异步发生,因此可能我们将面临一些变更检测问题。 此错误类型与简单错误具有不同的属性,因此,我们可能需要一种更合理的方法。 现在,我们通过为SanitisedError创建一个非常简单的界面来了解它 :
export interface SanitizedError {
message: string;
details: string[];
}
Let's create a template for our ErrorHandlerComponent:
让我们为我们的ErrorHandlerComponent创建一个模板:
// Imports
@Component({
selector: 'btp-error-handler',
template: `
Error
{
{error.message}}
{
{detail}}
`,
styleUrls: ['./error-handler.component.css'],
})
export class ErrorHandlerComponent implements OnInit {
// ...
}
We wrapped the whole modal into a and we added the cdkTrapFocus directive to it. This directive will prevent the user from navigating in the DOM behind our overlay/modal. The [cdkTrapFocusAutoCapture]="true" makes sure that the dismiss button is focused immediately. When the modal is closed the previously focused element will get back the focus. We simply display the error message and the details using *ngFor. Let's jump back into our ErrorHandlerService:
我们将整个模式包装到
// Imports
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
// Constructor
handleError(error: any): void {
const sanitised = this.sanitiseError(error);
const ngZone = this.injector.get(NgZone);
const overlayRef = this.overlay.create(DEFAULT_OVERLAY_CONFIG);
ngZone.run(() => {
this.attachPortal(overlayRef, sanitised).subscribe(() => {
overlayRef.dispose();
});
});
}
// ...
private sanitiseError(error: Error | HttpErrorResponse): SanitizedError {
const sanitisedError: SanitizedError = {
message: error.message,
details: []
};
if (error instanceof Error) {
sanitisedError.details.push(error.stack);
} else if (error instanceof HttpErrorResponse) {
sanitisedError.details = Object.keys(error)
.map((key: string) => `${key}: ${error[key]}`);
} else {
sanitisedError.details.push(JSON.stringify(error));
}
return sanitisedError;
}
// ...
}
With a rather simple sanitiseError method we create an object which is based on our previously defined interface. We check for error types and populate the data accordingly. The more interesting part is using the injector to get ngZone. When an error happens asynchronously, it usually happens outside change detection. We wrap our attachPortal with ngZone.run(/* ... */), so when an HttpErrorResponse is caught, it is rendered properly in our modal.
使用相当简单的sanitiseError方法,我们基于先前定义的接口创建了一个对象。 我们检查错误类型并相应地填充数据。 更有趣的部分是使用注射器获取ngZone 。 当错误异步发生时,通常会在更改检测之外发生。 我们使用ngZone.run(/ * ... * /)来包装attachPortal ,因此当捕获到HttpErrorResponse时,它将在我们的模式中正确呈现。
While the current state works nicely, it still lacks customisation. We use the Overlay from the CDK module, so exposing an injection token for custom configurations would be nice. Another important shortcoming of this module is that when this module is used, another module can't be used for error handling. For example, integrating Sentry would require you to implement a similar, but lightweight ErrorHandler module. In order to be able to use both, we should implement the possibility of using hooks inside our error handler. First, let's create our InjectionToken and our default configuration:
尽管当前状态运行良好,但仍缺乏定制。 我们使用CDK模块中的Overlay,因此可以为自定义配置公开注入令牌。 该模块的另一个重要缺点是,使用该模块时,不能将另一个模块用于错误处理。 例如,集成Sentry将要求您实现类似但轻量级的ErrorHandler模块。 为了能够同时使用两者,我们应该实现在错误处理程序内部使用钩子的可能性。 首先,让我们创建我们的InjectionToken和我们的默认配置:
import {InjectionToken} from '@angular/core';
import {DEFAULT_OVERLAY_CONFIG} from './constants/error-handler.constants';
import {ErrorHandlerConfig} from './interfaces/error-handler.interfaces';
export const DEFAULT_ERROR_HANDLER_CONFIG: ErrorHandlerConfig = {
overlayConfig: DEFAULT_OVERLAY_CONFIG,
errorHandlerHooks: []
};
export const ERROR_HANDLER_CONFIG: InjectionToken = new InjectionToken('btp-eh-conf');
Then provide it with our module, using our existing forRoot method:
然后使用现有的forRoot方法将其提供给我们的模块:
@NgModule({
declarations: [ErrorHandlerComponent],
imports: [CommonModule, OverlayModule, A11yModule],
entryComponents: [ErrorHandlerComponent]
})
export class ErrorHandlerModule {
public static forRoot(): ModuleWithProviders {
return {
ngModule: ErrorHandlerModule,
providers: [
{provide: ErrorHandler, useClass: ErrorHandlerService},
{provide: OverlayContainer, useClass: FullscreenOverlayContainer},
{provide: ERROR_HANDLER_CONFIG, useValue: DEFAULT_ERROR_HANDLER_CONFIG}
]
};
}
}
Then integrate this config handling into our ErrorHandlerService as well:
然后将这个配置处理也集成到我们的ErrorHandlerService中:
// Imports
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
// ...
handleError(error: any): void {
const sanitised = this.sanitiseError(error);
const {overlayConfig, errorHandlerHooks} = this.injector.get(ERROR_HANDLER_CONFIG);
const ngZone = this.injector.get(NgZone);
this.runHooks(errorHandlerHooks, error);
const overlayRef = this.createOverlayReference(overlayConfig);
ngZone.run(() => {
this.attachPortal(overlayRef, sanitised).subscribe(() => {
overlayRef.dispose();
});
});
}
// ...
private runHooks(errorHandlerHooks: Array<(error: any) => void> = [], error): void {
errorHandlerHooks.forEach((hook) => hook(error));
}
private createOverlayReference(overlayConfig: OverlayConfig): OverlayRef {
const overlaySettings: OverlayConfig = {...DEFAULT_OVERLAY_CONFIG, ...overlayConfig};
return this.overlay.create(overlaySettings);
}
// ...
}
And we are almost ready. Let's integrate a third-party error handler hook into our application:
我们几乎准备就绪。 让我们将第三方错误处理程序挂钩集成到我们的应用程序中:
// Imports
const CustomErrorHandlerConfig: ErrorHandlerConfig = {
errorHandlerHooks: [
ThirdPartyErrorLogger.logErrorMessage,
LoadingIndicatorControl.stopLoadingIndicator,
]
};
@NgModule({
declarations: [
AppComponent,
MainComponent
],
imports: [
BrowserModule,
HttpClientModule,
ErrorHandlerModule.forRoot(),
AppRoutingModule,
],
providers: [
{provide: ERROR_HANDLER_CONFIG, useValue: CustomErrorHandlerConfig}
],
bootstrap: [AppComponent]
})
export class AppModule {
}
As you can see, handling errors is an extremely important part of software development, but it can also be fun.
如您所见,处理错误是软件开发中极为重要的部分,但它也可能很有趣。
Thank you very much for reading this blog post. If you prefer reading code, please check out my ng-reusables git repository. You can also try out the implementation using this npm package.
非常感谢您阅读此博客文章。 如果您喜欢阅读代码,请查看我的ng-reusables git存储库 。 您也可以使用此npm包尝试实现。
You can also follow me on Twitter or GitHub.
您也可以在Twitter或GitHub上关注我。
翻译自: https://www.freecodecamp.org/news/global-error-handling-in-angular-with-the-help-of-the-cdk/
angular 错误处理