Skip to content

通过前面的章节,我们已经实现了一个相对完善的后端接口框架,也通过这个框架创建了一个简单的个人博客。

接下来,我们给接口框架添加 日志系统

日志 基本是每个后端框架都有的功能,用于记录系统发生的,包括请求、异常、错误、参数等内容。

这些内容全部存储到数据库,会造成数据库的大小剧集增加,而且大部分日志都是临时记录,主要用来做偶尔的问题追查。

所以,一般把这些记录存储到文件里,按照日志的时间和大小进行分割,避免日志文件过大,不方便查看。

经过对比,winston 比较符合我们的要求,而且更新也相对比较勤快。

bash
D:\codes\chensuiyi\nodejs-full-stack>pnpm add winston-daily-rotate-file winston
Packages: +32
++++++++++++++++++++++++++++++++
Progress: resolved 93, reused 92, downloaded 0, added 32, done

dependencies:
+ winston 3.17.0
+ winston-daily-rotate-file 5.0.0

Done in 3.5s

以上已经安装了 winstonwinston-daily-rotate-file 两个依赖,分别用来记录日志和分割日志。

js
import winston from 'winston';

const logger = winston.createLogger({
    level: 'info', // 日志级别
    format: winston.format.json(), // 以什么格式输出
    defaultMeta: {},
    transports: [
        //
        new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
        new winston.transports.File({ filename: 'logs/info.log' })
    ],
    exitOnError: false, // 发生异常不要退出
    silent: false // 正常打印日志
});

// 如果不是生产环境,则直接在控制台显示日志
if (process.env.NODE_ENV !== 'production') {
    logger.add(
        new winston.transports.Console({
            format: winston.format.simple()
        })
    );
}

以上是一个 winston 日志的初始化,主要关注 formattransports 两个属性,一个是日志的格式,一个是用什么来存储日志。

我们先集成到我们的项目中试试效果。

js
// /logger.js 文件
import winston from 'winston';
const logger = winston.createLogger({
    level: 'info', // 日志级别
    format: winston.format.json(), // 以什么格式输出
    defaultMeta: {
        // 默认添加到日志的数据
        service: 'user-service'
    },
    transports: [
        //
        new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
        new winston.transports.File({ filename: 'logs/info.log' })
    ],
    exitOnError: false, // 发生异常不要退出
    silent: false // 正常打印日志
});

// 如果不是生产环境,则直接在控制台显示日志
if (process.env.NODE_ENV !== 'production') {
    logger.add(
        new winston.transports.Console({
            format: winston.format.simple()
        })
    );
}

export { logger };

在项目根目录创建一个 logger.js 文件,内容如上,最后导出 logger 方法。

js
// /server.js 文件
import { createServer } from 'node:http';
import { readFileSync, existsSync } from 'node:fs';
import { createVerifier } from 'fast-jwt';
import { lookup } from 'mime-types';
import { apiMaps } from './routes.js';
import { config } from './config.js';
import { logger } from './logger.js'; // 看这里

const verifySync = createVerifier({ key: config.jwt.key });

const server = createServer(async (req, res) => {
    req.session = {};
    if (req.url.startsWith('/api/') === true) {
        res.setHeader('Content-Type', 'application/json');
        if (['/api/admin/login', '/api/admin/register'].includes(req.url) === false) {
            try {
                req.session = verifySync(req.headers.authorization?.split(' ')?.[1] || 'none');
            } catch (err) {
                res.end(
                    JSON.stringify({
                        code: 1,
                        msg: 'token 错误或已过期'
                    })
                );
                return;
            }
        }
        const apiName = req.url.replace('/api/', '');
        if (apiName === 'tool/upload') {
            // 文件上传
            const result = await apiMaps[apiName].default(req);
            res.end(JSON.stringify(result));
        } else {
            if (apiMaps[apiName]) {
                let body = '';
                req.on('data', (chunk) => {
                    body += chunk.toString();
                });
                req.on('end', async () => {
                    try {
                        req.body = JSON.parse(body);
                        logger.info(JSON.stringify({ url: req.url, session: req.session, body: req.body })); // 看这里
                        const result = await apiMaps[apiName].default(req);
                        res.end(JSON.stringify(result));
                    } catch (err) {
                        console.log('🚀 ~ req.on ~ err:', err);
                        res.end(
                            JSON.stringify({
                                code: 1,
                                msg: '请求参数结构有误'
                            })
                        );
                    }
                });
            } else {
                res.end(
                    JSON.stringify({
                        code: 1,
                        msg: '接口不存在'
                    })
                );
            }
        }
    } else {
        // 如果是资源
        let staticFile = req.url.split('?')?.[0] || '';
        if (staticFile === '/') {
            staticFile = '/index.html';
        }
        if (existsSync(`./public/${staticFile}`) === true) {
            res.setHeader('Content-Type', lookup(staticFile));
            res.end(readFileSync(`./public/${staticFile}`));
        } else {
            res.writeHead(404, { 'Content-Type': 'text/plain' });
            res.end('404 Not Found');
        }
    }
});

server.listen(3000, '127.0.0.1', () => {
    console.log('服务已启动,监听端口为:3000');
});

然后编辑 server.js 文件,请看以上注释为 看这里 的两行,分别是导入 logger 函数和记录当前请求的路径、当前请求的用户和请求的参数。

日志打印

启动项目,登录,添加文章,修改文章,所有的请求日志都在控制台可以看到了,这样我们就能知道,什么时间,什么人,请求了什么接口,发送了什么参数。

日志信息文件

同时,在 /logs/info.log 文件中,就记录了我们所有的请求信息,当我们需要追踪某个问题的时候,就能从日志中去查找蛛丝马迹了。

光有 请求日志 肯定是不足的,当我们的接口处理逻辑出现错误的时候,也需要记录 错误日志

js
// /apis/article/insert.js 文件
import { mysqlPool } from '../../mysql.js';
import { ajvValid } from '../../utils.js';
import { logger } from '../../logger.js'; // 看这里

export const apiSchema = {
    // 元数据
    meta: {
        tags: ['文章'],
        summary: '添加文章'
    },
    // 请求协议
    request: {
        type: 'object',
        properties: {
            title: {
                type: 'string',
                title: '用户名',
                minLength: 1,
                maxLength: 100
            },
            content: {
                type: 'string',
                title: '密码',
                minLength: 1,
                maxLength: 50000
            }
        },
        required: ['title', 'content'],
        additionalProperties: false
    },
    // 返回协议
    response: {
        type: 'object',
        properties: {
            token: {
                type: 'string',
                description: '登录令牌'
            }
        }
    }
};

export default async (req) => {
    try {
        await ajvValid(apiSchema.request, req.body);
        // ------------------------------------------------------
        // 从连接池中获取一个数据库连接
        const db = await mysqlPool.getConnection();
        // 查询数据库是否有对应的用户数据
        const [result] = await db.query({
            sql: 'INSERT INTO `article2` (`title`,`content`,`author`,`created_at`) VALUES (:title,:content,:author,:created_at)',
            values: {
                title: req.body.title,
                content: req.body.content,
                author: Number(req.session.id),
                created_at: Date.now()
            }
        });
        // 释放数据库连接
        db.release();
        // 返回成功信息
        return {
            code: 0,
            msg: '添加文章成功',
            data: {
                insertId: result.insertId
            }
        };
    } catch (err) {
        logger.error(JSON.stringify(err)); // 看这里
        return {
            code: 1,
            msg: '未知错误'
        };
    }
};

上面是添加文章的接口,同样引入日志函数,在 catch 分支里,通过 logger 打印错误日志。

请注意,我们的 sql 语句中,添加的表名称从 article 改成了 article2,这个表名是不存在的。

现在我们再看控制台打印的报错信息。

报错提示

可以看到,非常清晰地打印了,article2 表不存在的日志。

错误日志存储文件

此时再看我们的 /logs/error.log 文件,错误日志信息也被永久记录在日志文件中,再也不怕找不到错误原因了。

这里,我们的 错误日志普通日志 是分开的,就是为了方便我们查看报错信息。

前面我们说过,日志需要安装时间和大小进行分割,不然全部日志都放到一个文件,日积月累,那日志就大了去了,很不方便查看。

所以,接下来,我们使用 winston-daily-rotate-file 来进行日志分割。

js
// /logger.js 文件
import winston from 'winston';
import 'winston-daily-rotate-file';

const transportConfig = {
    dirname: 'logs', // 日志目录
    datePattern: 'YYYY-MM-DD', // 日志文件名
    maxSize: '1k', // 最大大小
    maxFiles: '30d' // 最大有效期为30天
};

const logger = winston.createLogger({
    level: 'info', // 日志级别
    format: winston.format.json(), // 以什么格式输出
    defaultMeta: {},
    transports: [
        //
        new winston.transports.DailyRotateFile({
            ...transportConfig,
            filename: 'error-%DATE%.log',
            level: 'error'
        }),
        new winston.transports.DailyRotateFile({
            ...transportConfig,
            filename: 'info-%DATE%.log',
            level: 'info'
        })
    ],
    exitOnError: false, // 发生异常不要退出
    silent: false // 正常打印日志
});

// 如果不是生产环境,则直接在控制台显示日志
if (process.env.NODE_ENV !== 'production') {
    logger.add(
        new winston.transports.Console({
            format: winston.format.simple()
        })
    );
}

export { logger };

调整后的日志创建代码如上,我们用 winston.transports.DailyRotateFile 来替代之前的 winston.transports.File

同时,transportConfig 变量也做了相应的注释,日志文件最大为 1kb 大小,保存时间最久为 30天

日志分割

接下来,我们不断发起请求,可以看到,日志就被以每个文件最大为 1kb 进行分割,保存在多个文件中。

这样就可以方便我们根据时间来查看日志,也不用一次性下载几十上百兆的文件来定位问题。

那么本文,或者说本小册的主要目标,就是让我们了解到一个后端框架可能会涉及到的方方面面。

通过自己动手实现一个后端接口框架,一个前后端对接了数据的博客系统,来体会全栈开发的感觉。

后续呢,大家可以关注我打磨了几年的 Node.js 后端接口开发框架 funpi,可以用它来开发实际生产中的项目。

开源地址:https://github.com/chenbimo/funpi

所有购买本小册的小伙伴,均可直接在本小册交流群无偿咨询,交流关于 funpi 框架的使用问题。

何以解忧,唯有代码。不忘初心,方得始终。