本教程示例使用nodejs来做一个自定义随机图的接口(我自己目前在用)

准备工作

首先你的电脑上需要node环境,去往官网下载最新版本的Node.js长期维护版本(本项目要求node>=14.0.0),我这里则是v22.17.1(LTS)

根据你的系统安装对应的程序。

源码分享

node项目中最重要的就是package.json文件,这个文件定义了环境,包名。

项目启动前的准备工作:

  1. 请在一个单独的空文件夹中

  2. 新建文件命名为package.json

package.json:

{
  "name": "random-image-api",
  "version": "1.0.0",
  "description": "随机图片API服务",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "engines": {
    "node": ">=14.0.0"
  },
  "author": "作者",
  "license": "MIT"
}
    

接下来请安装项目所需的环境:

npm install

安装环境后你的目录会多出一个node_modules文件夹。

2. 接下来和package.json同级目录下新建server.js文件

server.js:

const express = require('express');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');

// 配置文件路径(相对于服务器脚本)
const CONFIG_FILE = 'config.json';
// 日志中显示的最大文件数量
const MAX_LOG_FILES = 5;
// 日志颜色
const LOG_COLORS = {
  info: '\x1b[36m',    // 青色 - 信息
  success: '\x1b[32m', // 绿色 - 成功
  warning: '\x1b[33m', // 黄色 - 警告
  error: '\x1b[31m',   // 红色 - 错误
  reset: '\x1b[0m'     // 重置颜色
};

// 带时间戳的日志函数
function log(level, message) {
  const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
  const color = LOG_COLORS[level] || LOG_COLORS.info;
  console.log(`${color}[${timestamp}] [${level.toUpperCase()}] ${message}${LOG_COLORS.reset}`);
}

// 读取并验证配置文件
async function loadConfig() {
  try {
    log('info', '加载配置文件');
    // 读取配置文件
    const data = await fs.readFile(CONFIG_FILE, 'utf8');
    const config = JSON.parse(data);
    
    log('info', '验证配置结构');
    validateConfig(config);
    
    log('success', '配置加载成功');
    return config;
  } catch (err) {
    log('error', `配置错误: ${err.message}`);
    process.exit(1);
  }
}

// 验证配置文件格式
function validateConfig(config) {
  // 验证端口
  if (!config.port || typeof config.port !== 'number' || config.port <= 0) {
    throw new Error('缺少有效端口号');
  }
  
  // 验证路由
  if (!config.routes || typeof config.routes !== 'object') {
    throw new Error('缺少路由配置');
  }
  
  // 验证每个路由
  for (const [routePath, routeConfig] of Object.entries(config.routes)) {
    // 验证路由路径
    if (!routePath.startsWith('/')) {
      throw new Error(`路由路径必须以斜杠开头: ${routePath}`);
    }
    
    // 验证路由配置
    if (!routeConfig.type || !['image', 'text', 'directory'].includes(routeConfig.type)) {
      throw new Error(`无效的路由类型: ${routePath}`);
    }
    
    // 验证路径
    if (!routeConfig.path) {
      throw new Error(`缺少路径配置: ${routePath}`);
    }
    
    // 验证文件扩展名 (仅对 directory 类型需要)
    if (routeConfig.type === 'directory' && (!routeConfig.allowedExtensions || !Array.isArray(routeConfig.allowedExtensions) || routeConfig.allowedExtensions.length === 0)) {
      throw new Error(`缺少有效文件扩展名列表: ${routePath}`);
    }
    
    // 验证编码
    if (routeConfig.type === 'text' && !routeConfig.encoding) {
      routeConfig.encoding = 'utf8';
      log('warning', `文本路由 ${routePath} 未指定编码,使用默认值: utf8`);
    }
  }
}

// 处理目录请求(随机返回目录中的文件)
async function handleDirectoryRequest(res, routeConfig) {
  const { path: dirPath, allowedExtensions } = routeConfig;
  const fullPath = path.join(__dirname, dirPath);
  
  log('info', `处理目录请求: ${dirPath}`);
  
  // 检查目录是否存在
  if (!fsSync.existsSync(fullPath) || !fsSync.statSync(fullPath).isDirectory()) {
    log('error', `目录不存在: ${fullPath}`);
    return res.status(404).send(`错误:目录 "${dirPath}" 不存在`);
  }
  
  // 检查访问权限
  try {
    await fs.access(fullPath, fs.constants.R_OK);
  } catch {
    log('error', `无权限访问目录: ${fullPath}`);
    return res.status(403).send(`错误:没有权限访问目录 "${dirPath}"`);
  }
  
  // 读取目录中的文件
  const files = await fs.readdir(fullPath);
  const validFiles = files.filter(file => {
    const ext = path.extname(file).toLowerCase();
    return allowedExtensions.includes(ext);
  });
  
  // 优化日志输出:限制显示的文件数量
  if (validFiles.length > 0) {
    let fileListLog = validFiles.slice(0, MAX_LOG_FILES).join(', ');
    if (validFiles.length > MAX_LOG_FILES) {
      fileListLog += `, ... ${validFiles.length - MAX_LOG_FILES} more items`;
    }
    log('info', `目录 ${dirPath} 中的有效文件: ${fileListLog}`);
  } else {
    log('warning', `目录 ${dirPath} 中没有有效文件`);
  }
  
  // 检查是否有有效文件
  if (validFiles.length === 0) {
    return res.status(404).send(`错误:目录 "${dirPath}" 中没有有效文件`);
  }
  
  // 随机选择一个文件
  const randomIndex = Math.floor(Math.random() * validFiles.length);
  const randomFile = validFiles[randomIndex];
  const filePath = path.join(fullPath, randomFile);
  
  log('success', `随机选择文件: ${filePath}`);
  
  // 返回与 handleFileRequest 一致的结构
  return { filePath, fileName: randomFile };
}

// 处理文件请求
async function handleFileRequest(res, routeConfig) {
  const { path: filePath } = routeConfig;
  const fullPath = path.join(__dirname, filePath);
  
  log('info', `处理文件请求: ${filePath}`);
  
  // 检查文件是否存在
  if (!fsSync.existsSync(fullPath) || !fsSync.statSync(fullPath).isFile()) {
    log('error', `文件不存在: ${fullPath}`);
    return res.status(404).send(`错误:文件 "${filePath}" 不存在`);
  }
  
  // 检查访问权限
  try {
    await fs.access(fullPath, fs.constants.R_OK);
  } catch {
    log('error', `无权限访问文件: ${fullPath}`);
    return res.status(403).send(`错误:没有权限访问文件 "${filePath}"`);
  }
  
  log('success', `找到文件: ${fullPath}`);
  
  return { filePath: fullPath, fileName: path.basename(fullPath) };
}

// 处理请求
async function handleRequest(res, routeConfig) {
  let fileInfo;
  
  log('info', `处理请求: ${routeConfig.type} ${routeConfig.path}`);
  
  // 根据类型处理目录或文件
  if (routeConfig.type === 'directory') {
    fileInfo = await handleDirectoryRequest(res, routeConfig);
    if (!fileInfo) return; // 错误已由 handleDirectoryRequest 处理
  } else {
    fileInfo = await handleFileRequest(res, routeConfig);
    if (!fileInfo) return; // 错误已由 handleFileRequest 处理
  }
  
  // 确保文件信息有效
  if (!fileInfo.filePath) {
    log('error', '文件路径未定义');
    return res.status(500).send('错误:文件路径未定义');
  }
  
  const { filePath, fileName } = fileInfo;
  const ext = path.extname(fileName).toLowerCase();
  
  log('info', `准备发送文件: ${filePath}`);
  
  // 设置响应头
  let contentType = 'application/octet-stream';
  
  if (routeConfig.type === 'image' || (routeConfig.type === 'directory' && /\.(jpg|jpeg|png|webp|gif)$/i.test(fileName))) {
    if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg';
    if (ext === '.png') contentType = 'image/png';
    if (ext === '.webp') contentType = 'image/webp';
    if (ext === '.gif') contentType = 'image/gif';
    
    // 防止图片被缓存
    res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
    res.setHeader('Pragma', 'no-cache');
    res.setHeader('Expires', '0');
  } else if (routeConfig.type === 'text' || (routeConfig.type === 'directory' && /\.(txt|md|html|json|xml)$/i.test(fileName))) {
    // 设置文本内容类型并指定编码
    contentType = `text/plain; charset=${routeConfig.encoding || 'utf8'}`;
  }
  
  log('info', `设置 Content-Type: ${contentType}`);
  
  // 流式传输文件,隐藏实际文件路径
  const stream = fsSync.createReadStream(filePath);
  stream.pipe(res);
  
  log('success', `开始流式传输`);
  
  // 错误处理
  stream.on('error', (err) => {
    log('error', `文件传输错误: ${err.message}`);
    if (!res.headersSent) {
      res.status(500).send('错误:文件读取失败');
    }
  });
}

// 创建服务器并设置路由
async function setupServer(config) {
  log('info', '设置服务器路由');
  const app = express();
  
  // 设置所有路由
  for (const [routePath, routeConfig] of Object.entries(config.routes)) {
    app.get(routePath, async (req, res) => {
      try {
        log('info', `收到请求: ${routePath}`);
        await handleRequest(res, routeConfig);
      } catch (err) {
        log('error', `路由 "${routePath}" 错误: ${err.message}`);
        res.status(500).send(`服务器错误:${err.message}`);
      }
    });
  }
  
  // 404处理
  app.use((req, res) => {
    log('warning', `未找到路由: ${req.path}`);
    res.status(404).send('错误:未找到该路由');
  });
  
  log('success', '服务器路由设置完成');
  return app;
}

// 启动服务器
async function startServer() {
  log('info', '启动服务器');
  
  try {
    // 加载并验证配置
    const config = await loadConfig();
    
    // 设置服务器
    const app = await setupServer(config);
    
    // 启动服务器
    app.listen(config.port, () => {
      log('success', `服务器成功运行在端口 ${config.port}`);
      log('info', `配置文件: ${CONFIG_FILE}`);
      
      // 简洁显示路由列表
      const routesList = Object.keys(config.routes).join(',');
      log('info', `已加载的路由: [${routesList}]`);
    });
  } catch (err) {
    log('error', `服务器启动失败: ${err.message}`);
    process.exit(1);
  }
}

// 启动应用
startServer();

我将路由都定义好了,你只需要按照config.json来定义即可

config.json

{
  "port": 3366,
  "routes": {
    "/": {
      "type": "text",
      "path": "texts/welcome.txt",
      "encoding": "utf8" 
    },
    "/img": {
      "type": "directory",
      "path": "images/img",
      "allowedExtensions": [".jpg", ".jpeg", ".png", ".webp"]
    },
    "/text": {
      "type": "directory",
      "path": "texts",
      "allowedExtensions": [".txt", ".md"],
      "encoding": "gbk" 
    },
    "/specific-image": {
      "type": "image",
      "path": "specific/path/to/image.jpg"
    }
  }
}

config.json代码解释:

参数

解释

port

服务器监听的端口号

routes

路由配置,定义不同路径对应的内容类型和位置

/

/img

/text

根路径配置

随机图片路径

随机文本路径

"type": text

directory

image

类型若为文本,将返回指定文件的内容

目录类型,将随机返回目录中的文件

图片类型,直接返回指定图片

"path": texts/welcome.txt

images/img

texts

specific/path/to/image.jpg

文件路径,相对于服务器脚本位置

目录路径,相对于服务器脚本位置

目录路径

图片文件路径

"encoding": gbk、utf8或其他编码格式

文件编码,确保正确显示中文等特殊字符

"allowedExtensions": [".jpg", ".jpeg", ".png", ".webp",".txt", ".md"]

允许的文件扩展名,用于筛选文件