自己动手写一个Angular 日志框架

日志记录是软件行业中相当常见的话题。不幸的是,它在前端世界中并不常见,虽然在后端相关文章中经常提到。

然而,这个主题也适用于前端项目。

在本文中,我们将了解什么是日志记录以及在现代 Angular 应用程序中实现它的各种方法。

1. 记录日志有什么用?

首先,为什么我们要在 Angular 应用程序中记录任何内容?

这里我们可以区分两个方面:

  • 环境
  • 我们想要阅读的信息类型

1.1. 环境

从开发人员的角度来看,我们可能希望从应用程序的各个地方发生的事情中获得尽可能多的日志。

然而,从用户的角度来看,我不希望在开发工具中看到一堆日志或对后端的许多 HTTP 调用。

从环境(无论是生产环境、UAT 还是其他环境)来看,日志记录可能会发生变化,并提供有关应用程序的用途或使用方式的不同见解。

1.2. 信息类型

日志携带两类信息:它们的级别(是错误?常规操作?不寻常的东西?)和它们的有效负载,即实际内容。

通过使用这两个部分,我们可以提供对用户所做事情的洞察,功能日志(例如 New todo created by John)或技术日志(例如 Cache refreshed, 486 todo items synchronized)。

通过组合日志类型并根据环境区分它们,我们可以实现应用程序更好的可观察性,提高我们的故障排除能力和整体开发体验。

2. 内置方式

在 JavaScript 中,最知名的日志记录方法是调用该 console.log 方法,该方法将日志显示到控制台中:

1
2
3
4

>> console.log("hi tom!")
hi tom!

除了 log 方法之外,还可以使用其他方法来显示信息:

1
2
3
4
5
6
7

>> console.warn("Uh oh!")
Uh oh!

>> console.error("Oh no!")
Oh no!

还有许多其他您可能想阅读的内容

3. 添加日志

目前,我们没有任何日志,只能通过浏览代码来了解发生了什么。

通过利用该 console.log 方法,我们现在可以将一些日志添加到 TodoService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Injectable({ providedIn: "root" })
export class TodoService {
// ...

delete(idToDelete: number): void {
// ...
console.log("Todo Item #%d deleted", idToDelete);
}

setComplete(idToSet: number, isDone: boolean): void {
// ...
console.log(
"Todo Item #%d status set to %s",
idToSet,
isDone ? "done" : "pending"
);
}
}

3.1. 局限性

尽管拥有日志很棒,但这些日志将在部署站点时发送到生产环境。

更糟糕的是,console.xxx 它们不集中,使得在审查或提交代码时更难发现并且更容易忘记。

这些日志还缺少我们可能需要的一些功能,例如记录到不同的提供程序(HTTP、控制台,为什么不甚至是另一个 Angular 服务?)。

出于所有这些原因,最好将它们集中在专用组件中。

3.2. 利用 Angular 服务

我们可以创建一个简单的抽象来包装 console 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Injectable({ providedIn: "root" })
export class LoggerService {
info(template: string, ...optionalParams: any[]): void {
console.log(template, ...optionalParams);
}

warning(template: string, ...optionalParams: any[]): void {
console.warn(template, ...optionalParams);
}

error(template: string, ...optionalParams: any[]): void {
console.error(template, ...optionalParams);
}
}

只需使用这个新层,我们现在就可以在一个地方修改日志的行为。

例如,如果我们在生产中,我们可能希望禁用所有信息日志:

1
2
3
4
5
6
7
8
9
10

export class LoggerService {
info(template: string, ...optionalParams: any[]): void {
+ if (!isDevMode()) return;
console.log(template, ...optionalParams);
}

// ...
}

我们还可以强制执行特定的格式,例如添加当前时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

export class LoggerService {
+ #withDate(template: string): string {
+ return `${new Date().toLocaleTimeString()} | ${template}`;
+ }

info(template: string, ...optionalParams: any[]): void {
if (!isDevMode()) return;
- console.log(template, ...optionalParams);
+ console.log(this.#withDate(template), ...optionalParams);
}

// ...
}

3.3. 使用 LoggerService

LoggerService 在新文件中创建 logger.service.ts 并添加之前的代码以在消息中记录日期。

完成后,替换任何出现的 console.from TodoService,并将它们替换为对我们新的 LoggerService.

完成这些更改后,您现在应该可以看到日志及打印时间

3.4. 定义日志级别

虽然基础已经通过我们的服务建立起来,但如果我们想加强我们的日志,现在我们可以做很多事情。

定义日志级别
通常,有六个日志级别:

NameMeaningExample
TRACETracing of the execution flowStarting DoStuff()
DEBUGInformation helpful for debugging purposesValue of x: 42
INFOGeneral information about program executionApplication started
WARNINGIndication of potential issues or anomaliesId not found
ERRORDescribes an error that occurredUnable to connect to the database
FATALIndicates a critical failure in the programSystem crashed

我们可以用枚举来表示这些级别:

1
2
3
4
5
6
7
8
9
10
export enum LogLevel {
NEVER = Number.MAX_SAFE_INTEGER,

TRACE = 0,
DEBUG = 1,
INFO = 2,
WARNING = 3,
ERROR = 4,
FATAL = 5,
}

并 InjectionToken 为我们的应用程序公开一个默认日志级别:

1
2
3
4
5
6
7
8
9
10
export const MIN_LOG_LEVEL = new InjectionToken<LogLevel>("Minimum log level");

bootstrapApplication(AppComponent, {
providers: [
{
provide: MIN_LOG_LEVEL,
useValue: isDevMode() ? LogLevel.INFO : LogLevel.NEVER,
},
],
});

也可以直接从环境变量设置

LoggerService 通过使用这个令牌,我们可以限制基于它的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

export class LoggerService {
+ readonly #minLogLevel = inject(MIN_LOG_LEVEL) ?? LogLevel.NEVER;

+ #canLog(logLevel: LogLevel): boolean {
+ return logLevel >= this.#minLogLevel;
+ }

info(template: string, ...optionalParams: any[]): void {
- if (!isDevMode()) return;
+ if (!this.#canLog(LogLevel.INFO)) return;
console.log(this.#withDate(template), ...optionalParams);
}

// ...
}

为了 LoggerService 使用日志级别而不是检查 isDevMode,请 LogLevel 定义在新的 loglevel.enum.ts 文件中.

然后,如前所述定义”MIN_LOG_LEVEL” token, 并在 main.ts 以 provider 的方式以依赖注入的方式提供 MIN_LOG_LEVEL.

最后,使用前面的 LoggerService 代码片段来使用 token,定义#canLog 方法并将对的调用替换 isDevMode 为对新定义的#canLog 方法的调用。

完成后,您应该会发现应用程序的行为没有任何差异,但请尝试将其更改 MIN_LOG_LEVEL 为 LogLevel.NEVER:不再记录任何内容。

3.5. 使用 provider 注入 logging service

Angular 拥有非常强大的依赖注入系统,自从引入该 inject 功能后就更加强大了。

使用它,我们可以通过适当 privider 来无缝地组合服务。

在我们的 logger 上下文中,我们可以利用它根据环境轻松改变记录器的行为,而无需将逻辑传播到各处。

为此,我们可以定义一个接口,为我们的日志系统公开提供程序:

1
2
3
4
5
export interface LoggerProvider {
info(template: string, ...optionalParams: any[]): void;
warning(template: string, ...optionalParams: any[]): void;
error(template: string, ...optionalParams: any[]): void;
}

一种简单的实现可能是依赖于 console:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Injectable()
export class ConsoleProvider implements LoggerProvider {
info(template: string, ...optionalParams: any[]): void {
console.log(template, ...optionalParams);
}

warning(template: string, ...optionalParams: any[]): void {
console.warn(template, ...optionalParams);
}

error(template: string, ...optionalParams: any[]): void {
console.error(template, ...optionalParams);
}
}

由于这是一个 Injectable,我们绝对可以使用 HttpClient 例如,将日志发送到专用后端

然后我们可以利用 DI 系统的灵活性来定义一个新的 InjectionToken,提供所有已注册的 LoggerProvider:

export const LOGGER_PROVIDERS = new InjectionToken<LoggerProvider[]>(
“Providers for the logger”
);

并注册我们的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

+ function registerLoggerProviders(): EnvironmentProviders {
+ return makeEnvironmentProviders(
+ isDevMode()
+ ? [{ provide: LOGGER_PROVIDERS, useClass: ConsoleProvider, multi: true }]
+ : []
+ );
+}

bootstrapApplication(AppComponent, {
providers: [
+ registerLoggerProviders(),
{
provide: MIN_LOG_LEVEL,
useValue: isDevMode() ? LogLevel.INFO : LogLevel.NEVER,
},
],
});

请注意,注册是根据当前环境组成的:更改应用程序在运行时使用的提供程序只需要在这里进行更改!

我们的 logger 现在可以使用 LOGGER_PROVIDERS 令牌注入 logger 并仅仅依赖于 LoggerProvider 接口,而不是实现 LoggerProvider 的类,并且只关心何时调用它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

export class LoggerService {
readonly #minLogLevel = inject(MIN_LOG_LEVEL) ?? LogLevel.NEVER;
+ readonly #providers = inject(LOGGER_PROVIDERS) ?? [];

#canLog(logLevel: LogLevel): boolean {
return logLevel >= this.#minLogLevel;
}

info(template: string, ...optionalParams: any[]): void {
if (!this.#canLog(LogLevel.INFO)) return;
+ this.#providers.forEach((provider) =>
+ provider.info(template, ...optionalParams)
+ );
}

// ...
}

3.6. 添加自定义 LoggerProvider

按照前面的部分操作,以便 LoggerService 依赖所提供的 LoggerProvider。

再一次,除了我们不再在日志中添加时间这一事实之外,应用程序中不应看到任何更改:让我们将其恢复!

LoggerProvider 创建命名的新实现 TimedConsoleProvider。这个实现应该使用前面的#withDate 方法来格式化模板。

实现后,在 main.ts 文件中以 root 级别提供它,以便它可以与 ConsoleProvider 一起注入.

不要忘记设置 multi: true 为同一个 token 注册多个类

如果您按照这些步骤操作,您现在应该会看到每个操作的两个日志:一个由 编写 ConsoleProvider,另一个由 编写 TimedConsoleProvider:

4. 总结

在本文中,我们了解了日志记录对于前端应用程序的意义,并研究了一系列在 Angular 中集成日志记录的技术,从简单的方法到更复杂的方法。

在现实应用程序中,您可能更喜欢依赖标准化的第三方库,在配置方面提供更多选项,例如@ngworker/lumberjack 或 ngx-logger

日志记录可以帮助您更好地观察应用程序。然而,保持以用户为中心的方法至关重要。

在生产环境中过多记录技术细节或过多使用 HTTP 调用可能会降低整体用户体验。在信息记录和用户体验之间取得适当的平衡是确保应用程序无缝运行的关键。

5. 参考文档

What is going on here? Getting Started With Logging in Angular