Deno + Vue 全栈项目

基于 Deno + Oak 构建的服务端框架,支持代理到 Vue 开发服务器和生产环境打包。

首先我们来了解一下什么是Deno,Deno (/ˈdiːnoʊ/,发音为 dee-no) 是一个开源 JavaScript、TypeScript 和 WebAssembly 运行时,具有安全的默认设置和出色的开发者体验。它基于 V8、Rust 和 Tokio 构建。

技术栈

在当前项目中我们后端项目主要框架为

  • Deno
  • Oak (Web 框架)
  • MySQL

项目结构

bash 复制代码
.
├── server/                # 服务器端代码
│   ├── config/           # 配置文件
│   │   └── env.ts        # 环境变量配置
│   ├── middleware/       # 中间件
│   │   └── frontend.ts   # 前端代理中间件
│   ├── utils/           # 工具函数
│   │   └── bodyFormat.ts    # body格式化工具
│   │   └── cron.ts    # 定时任务工具
│   │   └── logger.ts    # 日志工具
│   │   └── mysql.ts    # mysql链接工具
│   │   └── request.ts    # web服务工具
└── deno.json           # Deno 配置文件
└── main.ts             # 服务器入口文件

创建一个deno项目

安装Deno

bash 复制代码
// Windows (PowerShell):
irm https://deno.land/install.ps1 | iex

// macOS/Linux:
curl -fsSL https://deno.land/x/install/install.sh | sh

创建项目

bash 复制代码
// 创建项目
mkdir my_project

cd ./my_project

// 初始化项目
deno init

初始化项目完成后会有一个简单的demo,此时我们可以运行起来查看

bash 复制代码
cd my_project
deno main.ts

此时我们已经有deno运行环境及项目初始化,接下来我们将开始配置后端服务

deno项目开发

在开发deno项目时使用vscode进行开发,此时我们还需要安装deno插件,来解决我们在开发中代码提醒,及错误检查,deno为TypeScript开发语言。

查看deno配置文件

在deno.json中存在taks模块

json 复制代码
"tasks": { "dev": "deno run --watch main.ts" }

我们可以在tasks中配置我们的deno运行命令,初始化后项目已经存在一个dev命令,可以通过deno run dev 来启动我们的项目,我们可以看到在dev配置中还包含了一个--watch配置,此配置作用为配置当前项目可以热跟新,修改后自动重启。

server服务编写

接下来我们将编写一个简单的server服,并且暴露一个test接口,我们通过http://localhost:8000/test 来访问此接口,并且放回我们在接口中配置的response

在项目中新建server.ts文件

TypeScript 复制代码
import { Application, Router } from "https://deno.land/x/oak/mod.ts";

export const server = async () => {
  // 创建应用
  const router = new Router();
  const app = new Application();
  router.get("/test", (ctx) => {
    ctx.response.body = "测试接口";
  });
  // 添加路由中间件
  app.use(router.routes());
  // 添加路由方法中间件
  app.use(router.allowedMethods());
  // 启动应用
  await app.listen(`localhost:8000`);
};

通过Application来创建中间件。通过 Router 来添加路由。

此时一个简单的server服务就算完成了,接下来我们可以在main中引用此方法,并且启动服务进行测试。

main.ts

TypeScript 复制代码
import { server } from "./server.ts";

export function add(a: number, b: number): number {
  return a + b;
} 

// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts
if (import.meta.main) {
  server();
  console.log("Add 2 + 3 =", add(2, 3));
}

if (import.meta.main)条件中加入我们编写的server()方法即可。

此时我们的main文件中也已经引入了server方法,我们可以使用deno run dev命令启动服务,启动服务后我们会在控制太看到

我们可以看到这里需要我们授权,deno跟node相比就是对于权限管理是相对比较严格的,此时我们可以在控制台输入y来进行授权,如果不想每次都通过控制台授权,deno也给了我们提示,可以通过run启动时 配置 --allow-net ,修改一下我们的deno.json文件。

json 复制代码
"tasks": {    "dev": "deno run --allow-net --watch main.ts"  },

此时我们在通过deno run dev 启动就不需要我们提供授权了。

启动完成后我们就可以通过 http://localhost:8000/test 来访问我的server服务了。

这里我们可以看到通过 http://localhost:8000/test访问到接口后返回的response也是我们在server中定义的放回结果。

这是一个相对来说简单的server工具类,我们在真实项目中可能会复杂一些,接下来我们来配置一个server工具类。不过我们在配置server工具类时我们应该先配置一下环境变量,避免我们在不同环境下时要修改我们配置,可以通过不同命令直接启动不同环境。

配置env环境

我们在开发过程中可能存在多个环境,在本项目中我以3个环境举例,分别为:developmentproductiontest

编写evn.ts

创建文件 server/config/env.ts

TypeScript 复制代码
// 环境类型
type EnvType = "development" | "production" | "test";
// 环境变量接口
interface EnvConfig {
  // 端口号
  PORT: number;
  // 主机地址
  HOST: string;
  // web项目地址
  WEB_URL: string;
  // API接口前缀
  PREFIX: string;
}
// 各环境配置
const envConfigs: Record<EnvType, EnvConfig> = {
  // 开发环境
  development: {
    PORT: 8000,
    HOST: "127.0.0.1",
    WEB_URL: "localhost:5173",
    PREFIX: "/api",
  },
  // 测试环境
  test: {
    PORT: 8001,
    HOST: "127.0.0.1",
    WEB_URL: "localhost:5173",
    PREFIX: "/api",
  },
  // 生产环境
  production: {
    PORT: 80,
    HOST: "0.0.0.0",
    WEB_URL: "localhost:5173",
    PREFIX: "/api",
  },
};
/** * 获取环境变量 */
export function getEnv(): EnvConfig {
  try {
    // 读取系统环境变量
    const sysEnv = Deno.env.toObject();
    // 确定当前环境
    const currentEnv = (sysEnv.DENO_ENV || "development") as EnvType;
    // 获取当前环境的默认配置
    const defaultConfig = envConfigs[currentEnv];
    if (!defaultConfig) {
      throw new Error(`未知的环境类型: ${currentEnv}`);
    }
    // 合并系统环境变量和默认配置
    //  // 系统环境变量优先级更高
    return {
      PORT: Number(sysEnv.PORT || defaultConfig.PORT),
      HOST: sysEnv.HOST || defaultConfig.HOST,
      WEB_URL: sysEnv.WEB_URL || defaultConfig.WEB_URL,
      PREFIX: sysEnv.API_URL || defaultConfig.PREFIX,
    };
  } catch (error) {
    // 返回开发环境默认配置
    return envConfigs.development;
  }
}
// 导出当前环境配置
export const env = getEnv();

环境变量我们配置好了,接下来编写server服务工具类。

编写server工具类

创建文件 server/utils/request.ts

TypeScript 复制代码
import {
  Application,
  HTTPMethods,
  Middleware,
  Router,
  RouterContext,
  isHttpError,
  Status,
} from "https://deno.land/x/oak/mod.ts";
import { env } from "../config/env.ts";
import { errorBody } from "./bodyFormat.ts";


/**
 * 路由配置接口
 * @interface RouterItem
 * @description 定义单个路由项的配置结构
 */
export interface RouterItem {
    /** 
     * 接口地址
     * @type {string}
     * @description API 端点的 URL 路径
     */
    url: string;
    /** 
     * 接口类型
     * @type {HTTPMethods}
     * @description HTTP 请求方法(GET, POST, PUT, DELETE 等)
     */
    method: HTTPMethods;
    /** 
     * 回调函数
     * @type {Function}
     * @description 处理请求的控制器函数
     * @param {RouterContext<string>} ctx - Oak 路由上下文对象
     */
    callback: (ctx: RouterContext<string>) => void
}


/**
 * 全局错误拦截器中间件
 * @type {Middleware}
 * @description 统一处理应用中的错误,将错误转换为标准的 HTTP 响应
 */
const errorMiddleware: Middleware = async (ctx, next) => {
  try {
    await next();
  } catch (err: unknown) {
    console.error(
      `服务器错误: ${err instanceof Error ? err.message : String(err)}`
    );
    // 确定状态码
    let status = Status.InternalServerError;
    if (isHttpError(err)) {
      status = err.status;
    }
    // 设置响应状态和错误信息
    ctx.response.status = status;
    ctx.response.body = errorBody(
      err instanceof Error ? err.message : "服务器内部错误"
    );
    // 防止进一步传播错误
    ctx.respond = true;
  }
};


/**
 * 启动服务器
 * @async
 * @function server
 * @description 配置并启动 Oak 服务器,设置中间件、路由和错误处理
 * @param {Array<RouterItem>} route - 路由配置数组
 * @returns {Promise<void>}
 */
export const server = async (route: Array<RouterItem>) => {

  // 获取环境变量配置
  const PORT = env.PORT;
  const HOST = env.HOST;
  const PREFIX = env.PREFIX;

  // 创建应用
  const router = new Router();
  const app = new Application();

  // 添加路由
  route.forEach((item) => {
    router.add(item.method, `${PREFIX}${item.url}`, item.callback);
  });

  // 添加全局错误处理中间件
  app.use(errorMiddleware);

  // 添加路由中间件
  app.use(router.routes());

  // 添加路由方法中间件
  app.use(router.allowedMethods());

  // 启动应用
  await app.listen(`${HOST}:${PORT}`);
};

到这里我们一个server服务工具类开发完成,我们将需要注册的接口通过调用server方法时传入,这里的route为入参,可以看到我们在添加路由时跟之前使用的方法不同,我们在单路由注册时我们通过具体方法去注册,在配置时我们可能不明白这个接口具体使用哪一个方法,oak在此时为我们提供了一个add方法,add方法存在3个参数,第一个就是们接口类型,第二个为接口地址,第三个为回调函数,回调中会方法我们tcx上下文。

我在response.body中想将返回格式固定,所以还编写了一个bodyFormat工具。

bodyFormat.ts

TypeScript 复制代码
/**
 * 成功返回格式
 * @param data 返回的数据
 * @returns 
 */
export const successBody = <T>(data: T) => {
    return {
        code: 200,
        data: data,
        status: 'success',
        message: '成功'
    }
}

/**
 * 错误返回格式
 * @param message 错误信息
 * @returns 
 */
export const errorBody = (message: string) => {
    return {
        code: 500,
        data: null,
        status: 'error',
        message: message
    }
}

接下来我们可以将server方法加入到main.ts中来调用看看。

main.ts

TypeScript 复制代码
import { successBody } from "./server/utils/bodyFormat.ts";
import { RouterItem, server } from "./server/utils/request.ts";


// 接口路由配置
const routerArr: Array<RouterItem> = [
  {
    url: "/test",
    method: "GET",
    callback: (ctx) => {
      ctx.response.body = successBody("测试接口");
    },
  },
];

if (import.meta.main) {
  server(routerArr);
}

因为我们配置了evn环境变量,所以我们还得在启动命令中加入DENO_ENV来确定我们的启动环境。

json 复制代码
"tasks": { "dev": "DENO_ENV=development deno run --allow-net --allow-env --watch main.ts"  }

这里我们指定dev启动环境为development开发环境。接下来我们可以通过deno run dev来启动我们的项目。启动完成后我们通过http://localhost:8000/api/test来访问我们服务,因为在配置时加入了PREFIX接口前缀所以在访问时也需要加入我们定义的前缀。

我们在这里能够看到,输出的response为一个json,因为我们使用了bodyFormat将我们的输出格式定义为了json。

到这里我们能够看到,此时已经具备一个项目开发的服务器接口要求了,但是此时我们还需要在加入一点点其他工具,就比如我们在正常项目中需要对用户的每一步操作进行一些本地日志写入,方便后期用来排查bug,所以我们接下来还需要编写一个日志工具类。

日志工具

在编写日志工具类之前,我考虑到我们写入日志时需要同时写入时间,所以我想到的是调用moment来为我们完成时间格式化操作,这里我们就不得不说deno的强大了,deno不关为我们提供了jsr库,我们同时还可以使用npm库,下面我们一起来操作一下。

bash 复制代码
deno add npm:moment

我们可以通过deno来安装npm库中所以插件,只需要在add操作时指定为npm库即可npm:插件名称

到这里我们的moment插件也安装成功了,我们来编写一下我们的日志工具吧。

创建文件 server/utils/logger.ts

TypeScript 复制代码
import moment from "npm:moment@^2.30.1";

/**
 * 日志级别枚举
 * @enum {string}
 */
export enum LogLevel {
  /** 信息级别日志 */
  INFO = 'INFO',
  /** 警告级别日志 */
  WARN = 'WARN',
  /** 错误级别日志 */
  ERROR = 'ERROR',
  /** 调试级别日志 */
  DEBUG = 'DEBUG'
}

/**
 * 日志配置接口
 */
export interface LogConfig {
  /** 日志文件路径 */
  logFilePath: string;
  /** 是否在控制台输出日志 */
  console?: boolean;
  /** 是否在日志中包含时间戳 */
  timestamp?: boolean;
}

/**
 * 日志记录类
 * 使用单例模式确保全局只有一个日志记录实例
 * 支持文件写入和控制台输出
 * 
 * @example
 * ```ts
 * const logger = Logger.getInstance({
 *   logFilePath: "./logs/app.log",
 *   console: true,
 *   timestamp: true
 * });
 * 
 * await logger.info("应用启动成功", { port: 3000 });
 * await logger.error("操作失败", { error: "连接超时" });
 * ```
 */
export class Logger {
  private static instance: Logger;
  private logFilePath: string;
  private enableConsole: boolean;
  private enableTimestamp: boolean;

  /**
   * 私有构造函数,防止直接实例化
   * @param {LogConfig} config - 日志配置
   */
  private constructor(config: LogConfig) {
    this.logFilePath = config.logFilePath;
    this.enableConsole = config.console ?? true;
    this.enableTimestamp = config.timestamp ?? true;
  }

  /**
   * 获取Logger的单例实例
   * @param {LogConfig} [config] - 可选的日志配置
   * @returns {Logger} Logger的单例实例
   */
  public static getInstance(config?: LogConfig): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger(config ?? {
        logFilePath: "./logs/app.log",
      });
    }
    return Logger.instance;
  }

  /**
   * 获取当前时间戳
   * @returns {string} 格式化的时间戳 YYYY-MM-DD HH:mm:ss
   * @private
   */
  private getTimestamp(): string {
    return moment().format('YYYY-MM-DD HH:mm:ss');
  }

  /**
   * 写入日志到文件
   * @param {string} message - 日志消息
   * @returns {Promise<void>}
   * @private
   */
  private async writeToFile(message: string): Promise<void> {
    try {
      // 确保目录存在
      const dir = this.logFilePath.substring(0, this.logFilePath.lastIndexOf("/"));
      try {
        await Deno.mkdir(dir, { recursive: true });
      } catch (_error: unknown) {
        // 目录可能已存在,忽略错误
      }

      // 写入日志
      await Deno.writeTextFile(
        this.logFilePath,
        message + "\n",
        { append: true }
      );
    } catch (error: unknown) {
      console.error(`写入日志文件失败: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

  /**
   * 格式化日志消息
   * @param {LogLevel} level - 日志级别
   * @param {string} message - 日志消息
   * @param {Record<string, unknown>} [meta] - 元数据
   * @returns {string} 格式化后的日志消息
   * @private
   */
  private formatMessage(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
    const timestamp = this.enableTimestamp ? `[${this.getTimestamp()}]` : "";
    const metaString = meta ? ` - ${JSON.stringify(meta)}` : "";
    return `${timestamp} [${level}] ${message}${metaString}`;
  }

  /**
   * 记录日志的核心方法
   * @param {LogLevel} level - 日志级别
   * @param {string} message - 日志消息
   * @param {Record<string, unknown>} [meta] - 元数据
   * @returns {Promise<void>}
   */
  public async log(level: LogLevel, message: string, meta?: Record<string, unknown>): Promise<void> {
    const formattedMessage = this.formatMessage(level, message, meta);
    
    if (this.enableConsole) {
      switch (level) {
        case LogLevel.ERROR:
          console.error(formattedMessage);
          break;
        case LogLevel.WARN:
          console.warn(formattedMessage);
          break;
        default:
          console.log(formattedMessage);
      }
    }

    await this.writeToFile(formattedMessage);
  }

  /**
   * 记录信息级别日志
   * @param {string} message - 日志消息
   * @param {Record<string, unknown>} [meta] - 元数据
   * @returns {Promise<void>}
   */
  public async info(message: string, meta?: Record<string, unknown>): Promise<void> {
    await this.log(LogLevel.INFO, message, meta);
  }

  /**
   * 记录警告级别日志
   * @param {string} message - 日志消息
   * @param {Record<string, unknown>} [meta] - 元数据
   * @returns {Promise<void>}
   */
  public async warn(message: string, meta?: Record<string, unknown>): Promise<void> {
    await this.log(LogLevel.WARN, message, meta);
  }

  /**
   * 记录错误级别日志
   * @param {string} message - 日志消息
   * @param {Record<string, unknown>} [meta] - 元数据
   * @returns {Promise<void>}
   */
  public async error(message: string, meta?: Record<string, unknown>): Promise<void> {
    await this.log(LogLevel.ERROR, message, meta);
  }

  /**
   * 记录调试级别日志
   * @param {string} message - 日志消息
   * @param {Record<string, unknown>} [meta] - 元数据
   * @returns {Promise<void>}
   */
  public async debug(message: string, meta?: Record<string, unknown>): Promise<void> {
    await this.log(LogLevel.DEBUG, message, meta);
  }

  /**
   * 设置日志文件路径
   * @param {string} path - 新的日志文件路径
   */
  public setLogFilePath(path: string): void {
    this.logFilePath = path;
  }

  /**
   * 设置是否在控制台输出日志
   * @param {boolean} enable - 是否启用控制台输出
   */
  public setConsoleOutput(enable: boolean): void {
    this.enableConsole = enable;
  }

  /**
   * 设置是否在日志中包含时间戳
   * @param {boolean} enable - 是否启用时间戳
   */
  public setTimestamp(enable: boolean): void {
    this.enableTimestamp = enable;
  }
}

// 创建默认实例
export const logger = Logger.getInstance(); 

具体实现逻辑为通过Deno提供的文件操作方法,在本地创建一个./logs/app.log文件,并在文件中写入我们的操作日志即可。

接下来我们需要将日志工具加入到我们的request.ts中。下面我们来修改一个request.ts文件

request.ts

TypeScript 复制代码
/**
 * @fileoverview 请求处理工具类,包含路由配置、中间件和服务器启动功能
 * @module server/utils/request
 */

import { Application, HTTPMethods, Middleware, Router, RouterContext, isHttpError, Status } from "https://deno.land/x/oak/mod.ts"
import { errorBody } from "./bodyFormat.ts";
import { logger } from "./logger.ts";
import { env } from "../config/env.ts";
import { mysql } from "./mysql.ts";

/**
 * 路由配置接口
 * @interface RouterItem
 * @description 定义单个路由项的配置结构
 */
export interface RouterItem {
    /** 
     * 接口地址
     * @type {string}
     * @description API 端点的 URL 路径
     */
    url: string;
    /** 
     * 接口类型
     * @type {HTTPMethods}
     * @description HTTP 请求方法(GET, POST, PUT, DELETE 等)
     */
    method: HTTPMethods;
    /** 
     * 回调函数
     * @type {Function}
     * @description 处理请求的控制器函数
     * @param {RouterContext<string>} ctx - Oak 路由上下文对象
     */
    callback: (ctx: RouterContext<string>) => void
}

/**
 * 请求日志中间件
 * @type {Middleware}
 * @description 记录所有 HTTP 请求的日志信息,包括请求开始、完成和错误情况
 */
const loggerMiddleware: Middleware = async (ctx, next) => {
    const start = Date.now();
    const { request } = ctx;
    
    // 生成请求ID
    const requestId = crypto.randomUUID();
    
    // 记录请求开始日志
    logger.info(`${requestId} | 请求开始 | ${request.method} ${request.url.pathname}`, {
        method: request.method,
        url: request.url.toString(),
        headers: Object.fromEntries(request.headers.entries()),
        ip: ctx.request.ip,
    });

    try {
        await next();
        
        // 计算响应时间
        const ms = Date.now() - start;
        
        // 记录请求完成日志
        logger.info(`${requestId} | 请求完成 | ${request.method} ${request.url.pathname} | ${ctx.response.status} | ${ms}ms`, {
            status: ctx.response.status,
            responseTime: ms,
        });
    } catch (error: unknown) {
        // 计算响应时间
        const ms = Date.now() - start;
        
        // 记录请求错误日志
        logger.error(`${requestId} | 请求错误 | ${request.method} ${request.url.pathname} | ${ms}ms`, {
            error: error instanceof Error ? error.message : String(error),
            stack: error instanceof Error ? error.stack : undefined,
        });
        
        // 继续抛出错误,让错误中间件处理
        throw error;
    }
};

/**
 * 全局错误拦截器中间件
 * @type {Middleware}
 * @description 统一处理应用中的错误,将错误转换为标准的 HTTP 响应
 */
const errorMiddleware: Middleware = async (ctx, next) => {
    try {
        await next();
    } catch (err: unknown) {
        console.error(`服务器错误: ${err instanceof Error ? err.message : String(err)}`);
        
        // 确定状态码
        let status = Status.InternalServerError;
        if (isHttpError(err)) {
            status = err.status;
        }
        
        // 设置响应状态和错误信息
        ctx.response.status = status;
        ctx.response.body = errorBody(err instanceof Error ? err.message : "服务器内部错误");
        
        // 防止进一步传播错误
        ctx.respond = true;
    }
};

/**
 * 启动服务器
 * @async
 * @function server
 * @description 配置并启动 Oak 服务器,设置中间件、路由和错误处理
 * @param {Array<RouterItem>} route - 路由配置数组
 * @returns {Promise<void>}
 */
export const server = async (route: Array<RouterItem>) => {
    // 获取环境变量配置
    const PORT = env.PORT;
    const HOST = env.HOST;
    const PREFIX = env.PREFIX;

    // 创建应用
    const router = new Router()
    const app = new Application()

    // 添加路由
    route.forEach(item => {
        router.add(item.method, `${PREFIX}${item.url}`, item.callback)
    })

    // 添加日志中间件
    app.use(loggerMiddleware);
    
    // 添加全局错误处理中间件
    app.use(errorMiddleware);

    /** 
     * 注册路由中间件
     * @description 启用应用的路由处理
     */
    app.use(router.routes())

    /** 
     * 注册路由方法中间件
     * @description 添加 HTTP 方法验证,确保请求方法的合法性
     */
    app.use(router.allowedMethods())

    /** 
     * 输出服务启动信息
     * @description 记录服务器配置和运行环境信息
     */
    logger.info(`服务启动成功: http://${HOST}:${PORT}`, {
        host: HOST,
        port: PORT,
        dbConnected: mysql.getConnectionStatus(),
        environment: Deno.env.get("DENO_ENV") || "development"
    });
    
    /** 
     * 启动应用服务器
     * @description 在指定的主机和端口上启动 HTTP 服务器
     */
    await app.listen(`${HOST}:${PORT}`)
}

到这里我们已经完成了一个server服务工具类了。不过我考虑到我们在写入日志文件时,如果不停写入可能会造成文件过大,所以我们还需要定期清理一下日志文件,此时我们就需要编写一个定时任务工具类来完成这一工作。

定时任务工具类编写

我们在平常开发过程中可能还需要使用定时任务来执行一些固定操作,在本项目中我想通过定时任务来删除我们日志文件,防止文件过大造成影响,所以我们需要编写一个定时任务工具类。

我们使用deno提供的cron来完成,cron具体使用说明可以通过官网来查看

创建 server/utils/cron.ts

cron.ts

TypeScript 复制代码
import { cron } from "https://deno.land/x/[email protected]/cron.ts";
import { Logger } from "./logger.ts";

/**
 * 定时任务配置接口
 */
interface CronJobConfig {
  /** 任务名称 */
  name: string;
  /** cron表达式 */
  schedule: string;
  /** 任务执行函数 */
  task: () => Promise<void> | void;
}

/**
 * 定时任务日志条目接口
 */
interface CronLogEntry {
  /** 任务名称 */
  jobName: string;
  /** 开始时间 */
  startTime: Date;
  /** 结束时间 */
  endTime: Date;
  /** 执行状态 */
  status: 'success' | 'error';
  /** 错误信息 */
  error?: string;
}

/**
 * 定时任务管理器
 * 使用单例模式确保全局只有一个定时任务管理实例
 * 
 * @example
 * ```ts
 * const cronManager = CronJobManager.getInstance();
 * await cronManager.addJob({
 *   name: "dailyBackup",
 *   schedule: "0 0 * * *",  // 每天午夜执行
 *   task: async () => {
 *     // 执行备份操作
 *   }
 * });
 * ```
 */
export class CronJobManager {
  private static instance: CronJobManager;
  private jobs: Map<string, CronJobConfig>;
  private logger: Logger;

  /**
   * 私有构造函数,防止直接实例化
   */
  private constructor() {
    this.jobs = new Map();
    this.logger = Logger.getInstance({
      logFilePath: "./logs/cron.log"
    });
  }

  /**
   * 获取CronJobManager的单例实例
   * @returns {CronJobManager} CronJobManager的单例实例
   */
  public static getInstance(): CronJobManager {
    if (!CronJobManager.instance) {
      CronJobManager.instance = new CronJobManager();
    }
    return CronJobManager.instance;
  }

  /**
   * 写入任务执行日志
   * @param {CronLogEntry} logEntry - 日志条目
   */
  private async writeLog(logEntry: CronLogEntry): Promise<void> {
    const duration = logEntry.endTime.getTime() - logEntry.startTime.getTime();
    const meta = {
      jobName: logEntry.jobName,
      startTime: logEntry.startTime.toISOString(),
      endTime: logEntry.endTime.toISOString(),
      duration: `${duration}ms`,
      status: logEntry.status,
      ...(logEntry.error && { error: logEntry.error })
    };

    if (logEntry.status === 'success') {
      await this.logger.info(`Cron job ${logEntry.jobName} completed successfully`, meta);
    } else {
      await this.logger.error(`Cron job ${logEntry.jobName} failed`, meta);
    }
  }

  /**
   * 添加新的定时任务
   * @param {CronJobConfig} config - 定时任务配置
   * @throws {Error} 当任务名称已存在时抛出错误
   * 
   * @example
   * ```ts
   * await cronManager.addJob({
   *   name: "dailyCleanup",
   *   schedule: "0 0 * * *",
   *   task: async () => {
   *     // 清理任务逻辑
   *   }
   * });
   * ```
   */
  public async addJob(config: CronJobConfig): Promise<void> {
    if (this.jobs.has(config.name)) {
      throw new Error(`Job with name ${config.name} already exists`);
    }

    this.jobs.set(config.name, config);
    await this.logger.info(`Adding new cron job: ${config.name} with schedule: ${config.schedule}`);

    cron(config.schedule, async () => {
      const startTime = new Date();
      try {
        await config.task();
        const endTime = new Date();
        await this.writeLog({
          jobName: config.name,
          startTime,
          endTime,
          status: "success",
        });
      } catch (error: unknown) {
        const endTime = new Date();
        await this.writeLog({
          jobName: config.name,
          startTime,
          endTime,
          status: "error",
          error: error instanceof Error ? error.message : String(error),
        });
      }
    });
  }

  /**
   * 移除指定的定时任务
   * @param {string} jobName - 要移除的任务名称
   * @throws {Error} 当任务不存在时抛出错误
   */
  public async removeJob(jobName: string): Promise<void> {
    if (!this.jobs.has(jobName)) {
      throw new Error(`Job with name ${jobName} does not exist`);
    }
    this.jobs.delete(jobName);
    await this.logger.info(`Removed cron job: ${jobName}`);
  }

  /**
   * 获取所有已注册的定时任务
   * @returns {Map<string, CronJobConfig>} 任务名称到任务配置的映射
   */
  public getJobs(): Map<string, CronJobConfig> {
    return new Map(this.jobs);
  }
}

我们还需要修改env.ts,将删除时间作为一个环境变量来进行维护。我们在evn中加入DEL_LOG_TIME日志文件删除时间这样一个变量。

接下来我们就可以修改一下main.ts来看看具体效果了。

TypeScript 复制代码
import { env } from "./server/config/env.ts";
import { successBody } from "./server/utils/bodyFormat.ts";
import { CronJobManager } from "./server/utils/cron.ts";
import { RouterItem, server } from "./server/utils/request.ts";

// 接口路由配置
const routerArr: Array<RouterItem> = [{
  url: "/test",
  method: "GET",
  callback: (ctx) => {
    ctx.response.body = successBody("测试接口");
  },
}];

if (import.meta.main) {
  server(routerArr);
  
  // 定时器删除日志文件,防止日志文件过大
  const cronManager = CronJobManager.getInstance();
  cronManager.addJob({
    name: "del_log",
    schedule: `0 */${env.DEL_LOG_TIME} * * *`,
    task: async () => {
      await Deno.remove("./logs/app.log");
    },
  });
}

可以看到我们在这里调用了addJob加入了一个定时任务,不过这个定时任务执行时间格式,不懂可以去看看官网的写法,我这里写法是以小时为单位。

到这里我们的定时任务工具类也编写完成了。接下来我们将要编写一个数据库链接工具,这里我们使用mysql作为项目数据库。

数据库工具类

当前项目以mysql数据库来完成,我是在本地安装了一个mysql数据库,大家如果有条件可以使用云服务来完成,没条件就本地安装吧。

我们在开始写之前需要安装一个mysql链接插件,这里我们使用npm中的mysql2工具

bash 复制代码
deno add npm:mysql2

安装完成后我们需要先修改env.ts 将数据库中配置给加入。

env.ts

TypeScript 复制代码
// 环境变量接口
interface EnvConfig {
  // 端口号
  PORT: number;
  // 主机地址
  HOST: string;
  // web项目地址
  WEB_URL: string;

  // API接口前缀
  PREFIX: string;

  // 日志文件多久删除一次
  DEL_LOG_TIME: number;

  // 数据库地址
  DB_URL: string;
  // 数据库端口
  DB_PORT: number;
  // 数据库用户名
  DB_USER: string;
  // 数据库密码
  DB_PASSWORD: string;
  // 数据库名称
  DB_DATABASE: string;
}

我们在环境变量中加入了数据库链接需要的所有变量,接下来我们就可以开始编写mysql.ts工具类了。

mysql.ts

创建 server/utils/mysql.ts

TypeScript 复制代码
/**
 * MySQL 数据库工具类
 * 使用单例模式确保全局只有一个数据库连接实例
 * 提供了常用的数据库操作方法和连接池管理
 *
 * @example
 * ```ts
 * const db = MySQLUtil.getInstance();
 * await db.connect();
 *
 * // 查询数据
 * const users = await db.findAll('users', { status: 'active' });
 *
 * // 插入数据
 * const result = await db.insert('users', { name: 'John', email: '[email protected]' });
 * ```
 */
import * as mysqlClient from "npm:mysql2@^2.3.3/promise";
import { env } from "../config/env.ts";
import { logger } from "./logger.ts";

/**
 * 查询参数接口
 * 用于定义查询条件的键值对
 */
export interface QueryParams {
  [key: string]: any;
}

/**
 * MySQL 工具类
 * 提供数据库连接管理和常用的 CRUD 操作
 */
export class MySQLUtil {
  private static instance: MySQLUtil;
  private pool: any;
  private connectionStatus = false;

  /**
   * 私有构造函数,防止直接实例化
   */
  private constructor() {
    this.pool = null;
  }

  /**
   * 获取 MySQLUtil 的单例实例
   * @returns {MySQLUtil} MySQLUtil 的单例实例
   */
  public static getInstance(): MySQLUtil {
    if (!MySQLUtil.instance) {
      MySQLUtil.instance = new MySQLUtil();
    }
    return MySQLUtil.instance;
  }

  /**
   * 连接数据库
   * 创建连接池并测试连接是否成功
   * @throws {Error} 当数据库连接失败时抛出错误
   */
  public async connect(): Promise<void> {
    if (this.connectionStatus) {
      return;
    }

    try {
      logger.info("正在连接数据库...", {
        host: env.DB_URL,
        user: env.DB_USER,
        database: env.DB_DATABASE,
      });

      // 尝试使用明确的配置
      this.pool = mysqlClient.createPool({
        host: env.DB_URL, // 如果失败,尝试使用 'localhost' 而非 IP 地址,或相反
        port: env.DB_PORT, // 明确指定端口
        user: env.DB_USER,
        password: env.DB_PASSWORD,
        database: env.DB_DATABASE,
        waitForConnections: true,
        connectionLimit: 10,
        queueLimit: 0,
      });

      // 测试连接
      const [result] = await this.pool.query("SELECT 1 AS connection_test");

      this.connectionStatus = true;
      logger.info("数据库连接成功", { result });
    } catch (error) {
      // 提供更详细的错误信息
      const errorMessage = error instanceof Error
        ? error.message
        : String(error);
      logger.error(`数据库连接失败: ${errorMessage}`, {
        host: env.DB_URL,
        user: env.DB_USER,
        database: env.DB_DATABASE,
        error: error,
      });

      throw new Error(
        `数据库连接失败: ${errorMessage},请检查数据库配置和服务状态`,
      );
    }
  }

  /**
   * 关闭数据库连接
   * 关闭连接池并释放资源
   * @throws {Error} 当关闭连接失败时抛出错误
   */
  public async close(): Promise<void> {
    if (!this.connectionStatus || !this.pool) {
      return;
    }

    try {
      await this.pool.end();
      this.connectionStatus = false;
      logger.info("数据库连接已关闭");
    } catch (error) {
      logger.error("关闭数据库连接失败", {
        error: error instanceof Error ? error.message : String(error),
      });
      throw error;
    }
  }

  /**
   * 执行 SQL 查询
   * @template T 返回数据的类型
   * @param {string} sql SQL 查询语句
   * @param {any[]} [params] 查询参数
   * @returns {Promise<T>} 查询结果
   * @throws {Error} 当查询执行失败时抛出错误
   */
  public async query<T>(sql: string, params?: any[]): Promise<T> {
    await this.connect();

    try {
      const startTime = Date.now();
      logger.debug(`执行SQL查询: ${sql}`, params ? { params } : undefined);

      const [rows] = await this.pool.query(sql, params);

      const endTime = Date.now();
      logger.info(`SQL查询成功 (${endTime - startTime}ms): ${sql}`, {
        params,
        rowCount: Array.isArray(rows) ? rows.length : 0,
        duration: endTime - startTime,
      });

      return rows as T;
    } catch (error) {
      logger.error(`SQL查询失败: ${sql}`, {
        params,
        error,
      });
      throw error;
    }
  }

  /**
   * 执行 SQL 语句(插入、更新、删除)
   * @param {string} sql SQL 语句
   * @param {any[]} [params] SQL 参数
   * @returns {Promise<{ affectedRows: number; insertId?: number }>} 执行结果,包含影响行数和插入ID
   * @throws {Error} 当执行失败时抛出错误
   */
  public async execute(
    sql: string,
    params?: any[],
  ): Promise<{ affectedRows: number; insertId?: number }> {
    await this.connect();

    try {
      const startTime = Date.now();
      logger.debug(`执行SQL语句: ${sql}`, params ? { params } : undefined);

      const [result] = await this.pool.execute(sql, params);

      const endTime = Date.now();
      logger.info(`SQL执行成功 (${endTime - startTime}ms): ${sql}`, {
        params,
        affectedRows: result.affectedRows,
        insertId: result.insertId,
        duration: endTime - startTime,
      });

      return {
        affectedRows: result.affectedRows,
        insertId: result.insertId,
      };
    } catch (error) {
      logger.error(`SQL执行失败: ${sql}`, {
        params,
        error,
      });
      throw error;
    }
  }

  /**
   * 查询多条记录
   * @template T 返回数据的类型
   * @param {string} table 表名
   * @param {QueryParams} [conditions] 查询条件
   * @param {string[]} [fields=['*']] 查询字段
   * @param {string} [orderBy] 排序条件
   * @param {number} [limit] 限制返回记录数
   * @param {number} [offset] 跳过记录数
   * @returns {Promise<T[]>} 查询结果数组
   */
  public async findAll<T>(
    table: string,
    conditions?: QueryParams,
    fields: string[] = ["*"],
    orderBy?: string,
    limit?: number,
    offset?: number,
  ): Promise<T[]> {
    let sql = `SELECT ${fields.join(", ")} FROM ${table}`;
    const params: any[] = [];

    // 添加条件
    if (conditions && Object.keys(conditions).length > 0) {
      const conditionClauses: string[] = [];
      for (const [key, value] of Object.entries(conditions)) {
        conditionClauses.push(`${key} = ?`);
        params.push(value);
      }
      sql += ` WHERE ${conditionClauses.join(" AND ")}`;
    }

    // 添加排序
    if (orderBy) {
      sql += ` ORDER BY ${orderBy}`;
    }

    // 添加分页
    if (limit !== undefined) {
      sql += ` LIMIT ?`;
      params.push(limit);

      if (offset !== undefined) {
        sql += ` OFFSET ?`;
        params.push(offset);
      }
    }
    logger.debug(`执行SQL查询: ${sql}`, { params });
    return await this.query<T[]>(sql, params);
  }

  /**
   * 查询单条记录
   * @template T 返回数据的类型
   * @param {string} table 表名
   * @param {QueryParams} conditions 查询条件
   * @param {string[]} [fields=['*']] 查询字段
   * @returns {Promise<T | null>} 查询结果,未找到时返回 null
   */
  public async findOne<T>(
    table: string,
    conditions: QueryParams,
    fields: string[] = ["*"],
  ): Promise<T | null> {
    const results = await this.findAll<T>(
      table,
      conditions,
      fields,
      undefined,
      1,
    );
    return results.length > 0 ? results[0] : null;
  }

  /**
   * 插入记录
   * @template T 数据类型
   * @param {string} table 表名
   * @param {QueryParams} data 要插入的数据
   * @returns {Promise<{ id: number; affectedRows: number }>} 插入结果,包含插入ID和影响行数
   * @throws {Error} 当插入数据为空时抛出错误
   */
  public async insert<T>(
    table: string,
    data: QueryParams,
  ): Promise<{ id: number; affectedRows: number }> {
    if (!data || Object.keys(data).length === 0) {
      throw new Error("插入数据不能为空");
    }

    const columns = Object.keys(data);
    const placeholders = columns.map(() => "?");
    const values = Object.values(data);

    const sql = `INSERT INTO ${table} (${columns.join(", ")}) VALUES (${
      placeholders.join(", ")
    })`;

    const result = await this.execute(sql, values);

    return {
      id: result.insertId || 0,
      affectedRows: result.affectedRows,
    };
  }

  /**
   * 更新记录
   * @param {string} table 表名
   * @param {QueryParams} data 要更新的数据
   * @param {QueryParams} conditions 更新条件
   * @returns {Promise<{ affectedRows: number }>} 更新结果,包含影响行数
   * @throws {Error} 当更新数据或条件为空时抛出错误
   */
  public async update(
    table: string,
    data: QueryParams,
    conditions: QueryParams,
  ): Promise<{ affectedRows: number }> {
    if (!data || Object.keys(data).length === 0) {
      throw new Error("更新数据不能为空");
    }

    if (!conditions || Object.keys(conditions).length === 0) {
      throw new Error("更新条件不能为空");
    }

    const updateClauses: string[] = [];
    const params: any[] = [];

    // 构建更新子句
    for (const [key, value] of Object.entries(data)) {
      updateClauses.push(`${key} = ?`);
      params.push(value);
    }

    // 构建条件子句
    const conditionClauses: string[] = [];
    for (const [key, value] of Object.entries(conditions)) {
      conditionClauses.push(`${key} = ?`);
      params.push(value);
    }

    const sql = `UPDATE ${table} SET ${updateClauses.join(", ")} WHERE ${
      conditionClauses.join(" AND ")
    }`;

    const result = await this.execute(sql, params);

    return {
      affectedRows: result.affectedRows,
    };
  }

  /**
   * 删除记录
   * @param {string} table 表名
   * @param {QueryParams} conditions 删除条件
   * @returns {Promise<{ affectedRows: number }>} 删除结果,包含影响行数
   * @throws {Error} 当删除条件为空时抛出错误
   */
  public async delete(
    table: string,
    conditions: QueryParams,
  ): Promise<{ affectedRows: number }> {
    if (!conditions || Object.keys(conditions).length === 0) {
      throw new Error("删除条件不能为空");
    }

    const conditionClauses: string[] = [];
    const params: any[] = [];

    // 构建条件子句
    for (const [key, value] of Object.entries(conditions)) {
      conditionClauses.push(`${key} = ?`);
      params.push(value);
    }

    const sql = `DELETE FROM ${table} WHERE ${conditionClauses.join(" AND ")}`;

    const result = await this.execute(sql, params);

    return {
      affectedRows: result.affectedRows,
    };
  }

  /**
   * 执行事务
   * @template T 返回数据的类型
   * @param {(conn: any) => Promise<T>} callback 事务回调函数
   * @returns {Promise<T>} 事务执行结果
   * @throws {Error} 当事务执行失败时抛出错误,会自动回滚
   *
   * @example
   * ```ts
   * const result = await mysql.transaction(async (conn) => {
   *   await conn.execute('INSERT INTO users (name) VALUES (?)', ['John']);
   *   await conn.execute('UPDATE accounts SET balance = balance - ?', [100]);
   *   return true;
   * });
   * ```
   */
  public async transaction<T>(callback: (conn: any) => Promise<T>): Promise<T> {
    await this.connect();

    const connection = await this.pool.getConnection();
    await connection.beginTransaction();

    try {
      logger.info("开始事务");
      const startTime = Date.now();

      const result = await callback(connection);

      await connection.commit();

      const endTime = Date.now();
      logger.info(`事务执行成功 (${endTime - startTime}ms)`);

      return result;
    } catch (error) {
      logger.error("事务执行失败,执行回滚", {
        error: error instanceof Error ? error.message : String(error),
      });
      await connection.rollback();
      throw error;
    } finally {
      connection.release();
    }
  }

  /**
   * 获取数据库连接状态
   * @returns {boolean} 当前连接状态
   */
  public getConnectionStatus(): boolean {
    return this.connectionStatus;
  }

  /**
   * 批量插入多条记录
   * @param {string} table 表名
   * @param {QueryParams[]} dataList 数据对象数组
   * @returns {Promise<{ affectedRows: number; insertId: number }>} 插入结果,包含影响行数和首个插入ID
   * @throws {Error} 当数据为空或数据对象的列不一致时抛出错误
   *
   * @example
   * ```ts
   * const users = [
   *   { name: 'John', email: '[email protected]' },
   *   { name: 'Jane', email: '[email protected]' }
   * ];
   * const result = await mysql.batchInsert('users', users);
   * ```
   */
  public async batchInsert(
    table: string,
    dataList: QueryParams[],
  ): Promise<{ affectedRows: number; insertId: number }> {
    if (!dataList || dataList.length === 0) {
      throw new Error("批量插入数据不能为空");
    }

    // 获取第一个对象的所有列名
    const firstItem = dataList[0];
    const columns = Object.keys(firstItem);

    if (columns.length === 0) {
      throw new Error("插入数据不能为空对象");
    }

    // 检查所有数据对象的列是否一致
    for (const data of dataList) {
      const currentColumns = Object.keys(data);
      if (
        currentColumns.length !== columns.length ||
        !columns.every((col) => currentColumns.includes(col))
      ) {
        throw new Error("批量插入的所有数据对象必须具有相同的列");
      }
    }

    // 构建占位符字符串 (?, ?, ?)
    const singlePlaceholder = `(${columns.map(() => "?").join(", ")})`;

    // 构建多个数据集的占位符 (?, ?, ?), (?, ?, ?), ...
    const placeholders = dataList.map(() => singlePlaceholder).join(", ");

    // 构建SQL语句
    const sql = `INSERT INTO ${table} (${
      columns.join(", ")
    }) VALUES ${placeholders}`;

    // 构建参数数组 [值1, 值2, ..., 值N]
    const params: any[] = [];
    for (const data of dataList) {
      for (const col of columns) {
        params.push(data[col]);
      }
    }

    // 执行SQL
    const startTime = Date.now();
    logger.debug(`执行批量插入: ${sql}`, { count: dataList.length });

    try {
      const [result] = await this.pool.execute(sql, params);

      const endTime = Date.now();
      logger.info(`批量插入成功 (${endTime - startTime}ms): ${table}`, {
        count: dataList.length,
        affectedRows: result.affectedRows,
        insertId: result.insertId,
        duration: endTime - startTime,
      });

      return {
        affectedRows: result.affectedRows,
        insertId: result.insertId,
      };
    } catch (error) {
      logger.error(`批量插入失败: ${table}`, {
        count: dataList.length,
        error,
      });
      throw error;
    }
  }
}

// 导出单例实例
export const mysql = MySQLUtil.getInstance();

到这里我们的数据链接工具也完成了,我们在项目启动时需要检测数据库是否能给链接上,所以我们要在main.ts中加入调用检查方法。

main.ts

TypeScript 复制代码
import { env } from "./server/config/env.ts";
import { successBody } from "./server/utils/bodyFormat.ts";
import { CronJobManager } from "./server/utils/cron.ts";
import { logger } from "./server/utils/logger.ts";
import { mysql } from "./server/utils/mysql.ts";
import { RouterItem, server } from "./server/utils/request.ts";

// 接口路由配置
const routerArr: Array<RouterItem> = [
  {
    url: "/test",
    method: "GET",
    callback: async (ctx) => {
      const res = await mysql.batchInsert("set_data_info", [{
        "file_name": "test1",
        "data_table": "test",
      }]);
      ctx.response.body = successBody(res);
    },
  },
];

if (import.meta.main) {
  // 启动服务
  server(routerArr);

  // 检测数据库连接
  try {
    logger.info("正在检测数据库连接...");
    await mysql.connect();
    logger.info("数据库连接检测成功");
  } catch (error) {
    logger.error("数据库连接失败,服务可能无法正常访问数据", {
      error: error instanceof Error ? error.message : String(error),
    });
    // 继续启动服务,但数据库操作可能会失败
  }

  // 定时器删除日志文件,防止日志文件过大
  const cronManager = CronJobManager.getInstance();
  cronManager.addJob({
    name: "del_log",
    schedule: `0 */${env.DEL_LOG_TIME} * * *`,
    task: async () => {
      await Deno.remove("./logs/app.log");
    },
  });
}

同时我还写了一个数据库插入方法,来验证我们是否能够联通数据库并且操作数据库,我在本地新建了一个deno数据库,数据库中存在一张set_data_info表,我们现在往表里插入一条数据。

我们在这里修改了一下test方法,我们来执行看看结果。

我们在执行后看到返回结果为已经往数据库中插入了一条数据,我们来看看数据库中是否存在这条数据。

我这里存在2条数据,因为我在之前已经执行过一次。

到这里我们的一个后端服务基本框架就已经搭建好了,我们将在下一个章节来学习前端代理中间件将我们vue项目代理到服务中通过端口直接访问,并且编写一些业务代码。

完整代码:github.com/dongbian1/d...

相关推荐
魔云连洲4 小时前
详细解释浏览器是如何渲染页面的?
前端·css·浏览器渲染
Kx…………5 小时前
Day2—3:前端项目uniapp壁纸实战
前端·css·学习·uni-app·html
培根芝士6 小时前
Electron打包支持多语言
前端·javascript·electron
mr_cmx7 小时前
Nodejs数据库单一连接模式和连接池模式的概述及写法
前端·数据库·node.js
东部欧安时7 小时前
研一自救指南 - 07. CSS面向面试学习
前端·css
涵信7 小时前
第十二节:原理深挖-React Fiber架构核心思想
前端·react.js·架构
ohMyGod_1237 小时前
React-useRef
前端·javascript·react.js
每一天,每一步7 小时前
AI语音助手 React 组件使用js-audio-recorder实现,将获取到的语音转成base64发送给后端,后端接口返回文本内容
前端·javascript·react.js
上趣工作室7 小时前
vue3专题1------父组件中更改子组件的属性
前端·javascript·vue.js
冯诺一没有曼7 小时前
无线网络入侵检测系统实战 | 基于React+Python的可视化安全平台开发详解
前端·安全·react.js