KOA技术分享

专注 Koa.js 框架的编程知识分享

Koa.js 日志系统设计与实现

日志系统的重要性

在生产环境中,完善的日志系统是排查问题、监控系统健康、分析用户行为的重要依据。一个好的日志系统应该满足以下要求:

日志框架选型

框架 特点 适用场景
Winston 功能最全,生态丰富 需要复杂日志处理
Pino 性能极高,内存占用小 高并发生产环境
Log4js 配置灵活, appender 多 需要多种输出方式

使用 Winston 实现日志系统

const winston = require('winston');
const path = require('path');
const logDir = path.join(__dirname, 'logs');

// 自定义日志格式
const customFormat = winston.format.combine(
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
  winston.format.errors({ stack: true }),
  winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
    let log = `${timestamp} [${level.toUpperCase()}]: ${message}`;
    if (Object.keys(meta).length > 0) {
      log += ` ${JSON.stringify(meta)}`;
    }
    if (stack) {
      log += `\n${stack}`;
    }
    return log;
  })
);

// JSON 格式(用于日志收集)
const jsonFormat = winston.format.combine(
  winston.format.timestamp(),
  winston.format.errors({ stack: true }),
  winston.format.json()
);

// 创建 logger
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: jsonFormat,
  defaultMeta: { service: 'koa-api' },
  transports: [
    // 控制台输出
    new winston.transports.Console({
      format: customFormat
    }),

    // 错误日志单独文件
    new winston.transports.File({
      filename: path.join(logDir, 'error.log'),
      level: 'error',
      maxsize: 10 * 1024 * 1024,  // 10MB
      maxFiles: 30
    }),

    // 全部日志
    new winston.transports.File({
      filename: path.join(logDir, 'combined.log'),
      maxsize: 10 * 1024 * 1024,
      maxFiles: 7
    }),

    // HTTP 访问日志
    new winston.transports.File({
      filename: path.join(logDir, 'access.log'),
      level: 'http',
      maxsize: 10 * 1024 * 1024,
      maxFiles: 7
    })
  ]
});

module.exports = logger;

Koa 中间件集成日志

const logger = require('./logger');

// 请求日志中间件
const requestLogger = async (ctx, next) => {
  const start = Date.now();

  // 等待请求完成
  await next();

  const ms = Date.now() - start;
  const logData = {
    method: ctx.method,
    url: ctx.url,
    status: ctx.status,
    responseTime: ms,
    ip: ctx.ip,
    userAgent: ctx.get('user-agent'),
    requestId: ctx.state.requestId || '-'
  };

  // 根据状态码选择日志级别
  if (ctx.status >= 500) {
    logger.error('Request error', logData);
  } else if (ctx.status >= 400) {
    logger.warn('Request warning', logData);
  } else {
    logger.info('Request', logData);
  }
};

// 统一错误处理中间件
const errorHandler = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    logger.error('Unhandled error', {
      error: err.message,
      stack: err.stack,
      url: ctx.url,
      method: ctx.method,
      body: ctx.request.body,
      query: ctx.query,
      requestId: ctx.state.requestId
    });

    ctx.status = err.status || 500;
    ctx.body = {
      code: -1,
      message: process.env.NODE_ENV === 'production'
        ? '服务器内部错误'
        : err.message
    };
  }
};

// 请求 ID 中间件
const requestId = async (ctx, next) => {
  ctx.state.requestId = ctx.get('X-Request-ID') || generateUUID();
  ctx.set('X-Request-ID', ctx.state.requestId);
  await next();
};

// 生成 UUID
function generateUUID() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

// 使用中间件
app.use(requestId);
app.use(requestLogger);
app.use(errorHandler);

业务日志封装

// 业务日志模块
class BizLogger {
  constructor(logger) {
    this.logger = logger;
  }

  // 用户操作日志
  logUserAction(userId, action, details = {}) {
    this.logger.info('User action', {
      type: 'user_action',
      userId,
      action,
      ...details
    });
  }

  // 业务操作日志
  logBizOperation(operation, result, details = {}) {
    this.logger.info('Business operation', {
      type: 'biz_operation',
      operation,
      result,
      ...details
    });
  }

  // 第三方调用日志
  logExternalCall(service, method, duration, success, details = {}) {
    const level = success ? 'info' : 'error';
    this.logger[level]('External call', {
      type: 'external_call',
      service,
      method,
      duration,
      success,
      ...details
    });
  }

  // 数据库操作日志
  logDbOperation(operation, table, duration, details = {}) {
    this.logger.debug('Database operation', {
      type: 'db_operation',
      operation,
      table,
      duration,
      ...details
    });
  }
}

const bizLogger = new BizLogger(logger);

// 使用示例
bizLogger.logUserAction('user123', 'login', { ip: '192.168.1.1' });
bizLogger.logExternalCall('payment', 'createOrder', 150, true, { orderId: 'order001' });

日志收集与分析

// 使用 Winston 传输到日志收集系统
const { ElasticsearchTransport } = require('winston-elasticsearch');
const ecsFormat = require('@elastic/ecs-winston-format');

const esTransport = new ElasticsearchTransport({
  level: 'info',
  clientOpts: {
    node: 'http://elasticsearch:9200',
    auth: {
      username: 'elastic',
      password: 'password'
    }
  },
  index: () => {
    const date = new Date();
    return `koa-logs-${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
  }
});

// 生产环境的 Logger 配置
const prodLogger = winston.createLogger({
  level: 'info',
  format: ecsFormat(),
  defaultMeta: { service: 'koa-api' },
  transports: [
    esTransport,
    new winston.transports.Console()
  ]
});

日志级别使用规范

级别 使用场景 示例
debug 开发调试信息 变量值、函数调用
info 正常业务流程 用户登录、订单创建
warn 可恢复的异常 参数校验失败、重试成功
error 需要处理的错误 数据库异常、服务调用失败

总结

日志系统是生产环境中不可或缺的基础设施。建议在开发阶段就建立完善的日志规范,统一日志格式,便于后续的问题排查和数据分析。对于大规模应用,可以结合 ELK(Elasticsearch + Logstash + Kibana)或 EFK 技术栈实现日志的集中管理和可视化分析。

← 下一篇:Koa 集成 WebSocket 实现实时通信