MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

巧用TypeScript装饰器实现日志记录功能

2021-08-134.3k 阅读

1. TypeScript 装饰器基础

在深入探讨如何使用 TypeScript 装饰器实现日志记录功能之前,我们先来回顾一下 TypeScript 装饰器的基础知识。

1.1 装饰器的定义

装饰器是一种特殊类型的声明,它能够附加到类声明、方法、属性或参数上,用来对这些目标进行元编程(Meta - programming)。简单来说,装饰器可以在不改变目标对象原有逻辑的基础上,为其添加额外的功能。

在 TypeScript 中,装饰器本质上是一个函数,这个函数接收目标对象(类、方法、属性等)作为参数,并可以返回一个新的对象来替换原有的目标对象。例如,一个简单的类装饰器可以写成如下形式:

function classDecorator(target: Function) {
    console.log('This is a class decorator, target is:', target);
    return target;
}

@classDecorator
class MyClass {
    // 类的定义
}

在上述代码中,classDecorator 是一个类装饰器,当它应用到 MyClass 类上时,会在控制台打印出目标类的信息。这里只是简单地打印信息,实际应用中可以做更多复杂的操作。

1.2 装饰器的类型

TypeScript 支持以下几种类型的装饰器:

  • 类装饰器:应用于类的定义,它接收类的构造函数作为参数。类装饰器可以用来修改类的行为,比如添加新的属性或方法。例如:
function enhanceClass(target: Function) {
    target.prototype.newMethod = function () {
        console.log('This is a new method added by the class decorator.');
    };
    return target;
}

@enhanceClass
class MyEnhancedClass {
    // 类的定义
}

let obj = new MyEnhancedClass();
obj.newMethod();
  • 方法装饰器:应用于类的方法,它接收三个参数:目标对象的原型(对于静态方法,是类本身)、方法名和描述符(包含方法的属性,如 valuewritableenumerableconfigurable)。方法装饰器可以用来修改方法的行为,比如在方法执行前后添加额外的逻辑。示例如下:
function logMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        console.log(`Calling method ${propertyKey} with arguments:`, args);
        const result = originalMethod.apply(this, args);
        console.log(`Method ${propertyKey} returned:`, result);
        return result;
    };
    return descriptor;
}

class MethodLoggerClass {
    @logMethod
    addNumbers(a: number, b: number) {
        return a + b;
    }
}

let methodLoggerObj = new MethodLoggerClass();
methodLoggerObj.addNumbers(2, 3);
  • 属性装饰器:应用于类的属性,它接收两个参数:目标对象的原型(对于静态属性,是类本身)和属性名。属性装饰器可以用来添加属性的元数据,比如标记属性的用途等。示例:
function markProperty(target: any, propertyKey: string) {
    Reflect.defineMetadata('purpose', 'example - property', target, propertyKey);
}

class PropertyMarkedClass {
    @markProperty
    myProperty: string;
}

let propertyMarkedObj = new PropertyMarkedClass();
let purpose = Reflect.getMetadata('purpose', propertyMarkedObj,'myProperty');
console.log('Property purpose:', purpose);
  • 参数装饰器:应用于类方法的参数,它接收三个参数:目标对象的原型(对于静态方法,是类本身)、方法名和参数在函数参数列表中的索引。参数装饰器可以用来记录方法调用时参数的信息等。例如:
function logParameter(target: any, propertyKey: string, parameterIndex: number) {
    const method = target[propertyKey];
    target[propertyKey] = function (...args: any[]) {
        console.log(`Parameter at index ${parameterIndex} in method ${propertyKey} is:`, args[parameterIndex]);
        return method.apply(this, args);
    };
}

class ParameterLoggerClass {
    someMethod(@logParameter param: string) {
        console.log('Method body with parameter:', param);
    }
}

let parameterLoggerObj = new ParameterLoggerClass();
parameterLoggerObj.someMethod('test');

2. 日志记录功能的需求分析

在软件开发中,日志记录是一项非常重要的功能。它可以帮助开发人员在调试过程中追踪程序的执行流程,了解程序在运行时的状态,以及定位和解决问题。对于一个日志记录功能,我们通常有以下基本需求:

2.1 记录方法调用信息

当一个方法被调用时,我们希望记录下方法的名称、传入的参数以及调用的时间。这有助于我们了解方法是在什么情况下被调用的,以及传递了哪些数据。例如,对于一个名为 calculateTotal 的方法,接收两个数字参数 ab,我们希望记录类似这样的信息:[2024 - 10 - 01 10:00:00] Method calculateTotal called with arguments [10, 20]

2.2 记录方法执行结果

除了方法调用信息,记录方法的执行结果也很关键。这样我们可以知道方法是否按照预期执行,以及返回了什么样的值。例如,对于上述 calculateTotal 方法,如果它返回 30,我们希望记录:[2024 - 10 - 01 10:00:00] Method calculateTotal returned 30

2.3 异常处理与记录

在方法执行过程中,如果发生异常,我们需要捕获并记录异常信息,包括异常类型、异常消息以及异常发生的位置。这对于快速定位和解决问题至关重要。例如,如果 calculateTotal 方法由于参数类型错误抛出异常,我们希望记录:[2024 - 10 - 01 10:00:00] Method calculateTotal threw an error: TypeError - Invalid parameter types

2.4 灵活的日志级别控制

在不同的环境(开发、测试、生产)中,我们可能需要不同详细程度的日志。因此,日志记录功能应该支持灵活的日志级别控制,比如分为 DEBUGINFOWARNERROR 等级别。在开发环境中,我们可能希望记录所有级别的日志以便调试;而在生产环境中,可能只记录 WARNERROR 级别的日志,以减少日志量对系统性能的影响。

3. 使用 TypeScript 装饰器实现日志记录功能

基于上述需求分析,我们可以利用 TypeScript 装饰器来实现一个功能较为完备的日志记录系统。

3.1 实现基本的方法调用与结果记录

我们首先实现一个简单的方法装饰器,用于记录方法的调用信息和执行结果。

function basicLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        const now = new Date();
        console.log(`[${now.toISOString()}] Method ${propertyKey} called with arguments:`, args);
        const result = originalMethod.apply(this, args);
        console.log(`[${now.toISOString()}] Method ${propertyKey} returned:`, result);
        return result;
    };
    return descriptor;
}

class BasicLoggerClass {
    @basicLog
    multiplyNumbers(a: number, b: number) {
        return a * b;
    }
}

let basicLoggerObj = new BasicLoggerClass();
basicLoggerObj.multiplyNumbers(3, 4);

在上述代码中,basicLog 装饰器在方法调用前记录了方法名和参数,在方法调用后记录了返回结果。

3.2 加入异常处理与记录

为了让日志记录功能更完善,我们需要加入异常处理和记录的逻辑。

function enhancedLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        const now = new Date();
        try {
            console.log(`[${now.toISOString()}] Method ${propertyKey} called with arguments:`, args);
            const result = originalMethod.apply(this, args);
            console.log(`[${now.toISOString()}] Method ${propertyKey} returned:`, result);
            return result;
        } catch (error) {
            console.error(`[${now.toISOString()}] Method ${propertyKey} threw an error:`, error);
            throw error;
        }
    };
    return descriptor;
}

class EnhancedLoggerClass {
    @enhancedLog
    divideNumbers(a: number, b: number) {
        if (b === 0) {
            throw new Error('Division by zero');
        }
        return a / b;
    }
}

let enhancedLoggerObj = new EnhancedLoggerClass();
try {
    enhancedLoggerObj.divideNumbers(10, 2);
    enhancedLoggerObj.divideNumbers(10, 0);
} catch (error) {
    // 这里可以捕获到异常,但主要的异常记录在装饰器内部已经完成
}

enhancedLog 装饰器中,我们使用 try - catch 块来捕获方法执行过程中的异常,并记录异常信息。

3.3 实现日志级别控制

为了实现日志级别控制,我们可以引入一个日志级别枚举,并在装饰器中根据设置的日志级别来决定是否记录日志。

enum LogLevel {
    DEBUG = 'DEBUG',
    INFO = 'INFO',
    WARN = 'WARN',
    ERROR = 'ERROR'
}

let currentLogLevel = LogLevel.INFO;

function logWithLevel(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        const now = new Date();
        const logLevel = LogLevel.INFO; // 这里可以根据方法特性设置不同的默认日志级别,暂设为 INFO
        if (logLevel === LogLevel.DEBUG && currentLogLevel === LogLevel.DEBUG) {
            console.log(`[${now.toISOString()}] [DEBUG] Method ${propertyKey} called with arguments:`, args);
        } else if (logLevel === LogLevel.INFO && (currentLogLevel === LogLevel.INFO || currentLogLevel === LogLevel.DEBUG)) {
            console.log(`[${now.toISOString()}] [INFO] Method ${propertyKey} called with arguments:`, args);
        }
        try {
            const result = originalMethod.apply(this, args);
            if (logLevel === LogLevel.DEBUG && currentLogLevel === LogLevel.DEBUG) {
                console.log(`[${now.toISOString()}] [DEBUG] Method ${propertyKey} returned:`, result);
            } else if (logLevel === LogLevel.INFO && (currentLogLevel === LogLevel.INFO || currentLogLevel === LogLevel.DEBUG)) {
                console.log(`[${now.toISOString()}] [INFO] Method ${propertyKey} returned:`, result);
            }
            return result;
        } catch (error) {
            if (logLevel === LogLevel.ERROR || currentLogLevel === LogLevel.ERROR) {
                console.error(`[${now.toISOString()}] [ERROR] Method ${propertyKey} threw an error:`, error);
            }
            throw error;
        }
    };
    return descriptor;
}

class LevelLoggerClass {
    @logWithLevel
    subtractNumbers(a: number, b: number) {
        return a - b;
    }
}

let levelLoggerObj = new LevelLoggerClass();
levelLoggerObj.subtractNumbers(5, 3);

在上述代码中,我们定义了 LogLevel 枚举来表示不同的日志级别,并通过 currentLogLevel 变量来设置当前的日志级别。logWithLevel 装饰器会根据设置的日志级别来决定是否记录日志。

4. 优化与扩展日志记录功能

虽然我们已经实现了一个基本的日志记录功能,但还有一些方面可以进一步优化和扩展。

4.1 日志输出格式优化

目前,我们的日志输出格式比较简单,只是打印了时间、日志级别、方法名、参数和结果/异常信息。在实际应用中,我们可以将日志格式化为更易于阅读和分析的形式,比如 JSON 格式。

function jsonLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        const now = new Date();
        const logEntry: {
            timestamp: string;
            logLevel: string;
            method: string;
            arguments: any[];
            result?: any;
            error?: any;
        } = {
            timestamp: now.toISOString(),
            logLevel: 'INFO',
            method: propertyKey,
            arguments: args
        };
        try {
            const result = originalMethod.apply(this, args);
            logEntry.result = result;
            console.log(JSON.stringify(logEntry));
            return result;
        } catch (error) {
            logEntry.error = error;
            console.error(JSON.stringify(logEntry));
            throw error;
        }
    };
    return descriptor;
}

class JsonLoggerClass {
    @jsonLog
    powerNumbers(a: number, b: number) {
        return Math.pow(a, b);
    }
}

let jsonLoggerObj = new JsonLoggerClass();
jsonLoggerObj.powerNumbers(2, 3);

jsonLog 装饰器中,我们将日志信息整理成一个对象,并使用 JSON.stringify 方法将其格式化为 JSON 字符串输出。这样的格式更便于在日志分析工具中进行处理和查询。

4.2 日志存储与持久化

目前,我们只是将日志打印到控制台,这在开发和测试阶段可能足够,但在生产环境中,我们需要将日志存储起来以便后续分析和审计。我们可以使用文件系统、数据库或者专门的日志管理系统来存储日志。以下是一个简单的将日志记录到文件的示例,使用 fs 模块(Node.js 环境):

import fs from 'fs';
import path from 'path';

function fileLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    const logFilePath = path.join(__dirname, 'logs', `${Date.now()}_${propertyKey}.log`);
    if (!fs.existsSync(path.dirname(logFilePath))) {
        fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
    }
    descriptor.value = function (...args: any[]) {
        const now = new Date();
        const logEntry = `[${now.toISOString()}] Method ${propertyKey} called with arguments: ${JSON.stringify(args)}\n`;
        fs.appendFileSync(logFilePath, logEntry);
        try {
            const result = originalMethod.apply(this, args);
            const resultEntry = `[${now.toISOString()}] Method ${propertyKey} returned: ${JSON.stringify(result)}\n`;
            fs.appendFileSync(logFilePath, resultEntry);
            return result;
        } catch (error) {
            const errorEntry = `[${now.toISOString()}] Method ${propertyKey} threw an error: ${JSON.stringify(error)}\n`;
            fs.appendFileSync(logFilePath, errorEntry);
            throw error;
        }
    };
    return descriptor;
}

class FileLoggerClass {
    @fileLog
    sumArray(numbers: number[]) {
        return numbers.reduce((acc, num) => acc + num, 0);
    }
}

let fileLoggerObj = new FileLoggerClass();
fileLoggerObj.sumArray([1, 2, 3, 4, 5]);

fileLog 装饰器中,我们使用 fs.appendFileSync 方法将日志信息追加到指定的文件中。这里为每个方法调用创建一个单独的日志文件,文件名包含时间戳和方法名。实际应用中,可以根据需求调整日志文件的存储策略,比如按天、按周进行归档等。

4.3 上下文信息记录

在复杂的应用程序中,记录方法调用的上下文信息也非常有帮助。上下文信息可以包括用户 ID、请求 ID、事务 ID 等,这些信息有助于在分布式系统中追踪和关联不同的方法调用。我们可以通过在装饰器中传递额外的上下文参数来实现这一点。

function contextLog(context: { [key: string]: any }) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function (...args: any[]) {
            const now = new Date();
            const logEntry: {
                timestamp: string;
                context: { [key: string]: any };
                method: string;
                arguments: any[];
                result?: any;
                error?: any;
            } = {
                timestamp: now.toISOString(),
                context,
                method: propertyKey,
                arguments: args
            };
            try {
                const result = originalMethod.apply(this, args);
                logEntry.result = result;
                console.log(JSON.stringify(logEntry));
                return result;
            } catch (error) {
                logEntry.error = error;
                console.error(JSON.stringify(logEntry));
                throw error;
            }
        };
        return descriptor;
    };
}

class ContextLoggerClass {
    @contextLog({ userId: 123, requestId: 'abc - 123' })
    processData(data: string) {
        return `Processed: ${data}`;
    }
}

let contextLoggerObj = new ContextLoggerClass();
contextLoggerObj.processData('Sample data');

在上述代码中,我们定义了一个高阶函数 contextLog,它接收一个上下文对象作为参数,并返回一个装饰器函数。这个装饰器函数会将上下文信息包含在日志记录中。

5. 注意事项与最佳实践

在使用 TypeScript 装饰器实现日志记录功能时,有一些注意事项和最佳实践需要遵循。

5.1 性能影响

装饰器会在目标对象定义时执行,虽然现代 JavaScript 引擎对函数调用的性能优化已经很好,但过多的装饰器或者装饰器内部复杂的逻辑可能会对性能产生一定的影响。因此,在生产环境中,要谨慎使用装饰器,特别是那些执行复杂计算或者 I/O 操作的装饰器。

5.2 兼容性

虽然 TypeScript 支持装饰器,但不同的 JavaScript 运行环境(如浏览器、Node.js)对装饰器的支持程度可能不同。在使用装饰器时,要确保目标运行环境能够正确解析和执行装饰器代码。如果需要在不同环境中使用,可以考虑使用转译工具(如 Babel)来将装饰器代码转换为更兼容的 JavaScript 代码。

5.3 装饰器的顺序

当一个目标对象上应用多个装饰器时,装饰器的顺序很重要。例如,对于方法装饰器,装饰器从下往上(靠近方法定义的装饰器先执行)依次执行。要根据具体的功能需求合理安排装饰器的顺序,以确保日志记录功能与其他功能之间不会产生冲突。

5.4 可维护性

随着项目的发展,日志记录功能可能会不断扩展和修改。为了保证代码的可维护性,建议将日志记录相关的逻辑封装在独立的模块中,并且使用清晰的命名和注释。这样可以使代码更易于理解和修改,同时也方便团队成员之间的协作。

6. 总结

通过使用 TypeScript 装饰器,我们可以优雅地为类的方法添加日志记录功能。从基本的方法调用与结果记录,到加入异常处理、日志级别控制,再到优化日志输出格式、实现日志存储与持久化以及记录上下文信息,我们逐步构建了一个功能较为完备的日志记录系统。在实际应用中,根据项目的具体需求和运行环境,我们可以对这些功能进行进一步的调整和扩展。同时,遵循性能、兼容性、顺序和可维护性等方面的最佳实践,能够确保日志记录功能在项目中稳定、高效地运行,为软件开发和维护提供有力的支持。