【Node手写对象存储】桶的前端项目托管与本地资源开放网络访问模式实现

214 阅读13分钟

这是手写对象存储专栏的第7章,本文将介绍如何在对象存储系统中实现前端项目托管和本地资源的开放网络访问。我们将利用 Node.js 和 Express 作为服务器框架,结合 Mongoose 管理 MongoDB 数据库,使用 Multer 处理文件上传,并通过文件系统操作和 ZIP 解压实现文件的管理和版本控制。最终,用户将能够方便地上传、管理和访问其静态资源。

实现本地资源开放网络访问

为了实现存储桶的不同模式,我们需要在创建存储桶时,根据模式执行相应的逻辑。特别是对于“本地资源开放网络访问”模式,我们需要创建一个异步任务来扫描指定的本地目录,并将文件信息更新到 ObjectMetadata 表中。

更新存储桶模型

首先,我们需要更新存储桶模型src/models/BucketModel.ts以支持新建存储桶时的异步任务。

import mongoose from "mongoose";
import BaseModel, { IBaseDocument } from "./BaseModel";
import path from "path";
import { v4 as uuidv4 } from "uuid";
import { scanLocalDirectoryAndUpdateMetadata } from "../utils/scanDirectory";

// 定义枚举
export enum BucketMode {
  STANDARD = "Standard",
  FRONTEND_HOSTING = "FrontendHosting",
  LOCAL_RESOURCES = "LocalResources",
}

const basePath = "/media/plyue/662C8A672C8A31DB6/ore";

// 定义存储桶接口
export interface IBucket extends IBaseDocument {
  bucketName: string; // 存储桶名称
  accessControl: string; // 访问控制设置
  quota: number; // 存储配额
  encryptionSettings: {
    enabled: boolean; // 是否启用加密
    algorithm?: string; // 加密算法(可选)
  };
  loggingConfiguration: {
    enabled: boolean; // 是否启用日志记录
    targetBucket?: string; // 日志目标存储桶(可选)
    targetPrefix?: string; // 日志目标前缀(可选)
  };
  tags: Record<string, string>; // 存储桶标签
  mode: BucketMode; // 存储桶模式
  path?: string; // 存储桶路径(可选)
  versions: { versionId: string; path: string }[]; // 版本管理
}

// 定义存储桶模式的 Schema
const bucketSchemaDefinition: mongoose.SchemaDefinition = {
  bucketName: {
    type: String,
    required: true,
    unique: true,
    comment: "存储桶名称",
  },
  accessControl: { type: String, required: true, comment: "访问控制设置" },
  quota: { type: Number, required: true, comment: "存储配额" },
  encryptionSettings: {
    enabled: { type: Boolean, required: true, comment: "是否启用加密" },
    algorithm: { type: String, required: false, comment: "加密算法(可选)" },
  },
  loggingConfiguration: {
    enabled: { type: Boolean, required: true, comment: "是否启用日志记录" },
    targetBucket: {
      type: String,
      required: false,
      comment: "日志目标存储桶(可选)",
    },
    targetPrefix: {
      type: String,
      required: false,
      comment: "日志目标前缀(可选)",
    },
  },
  tags: { type: Map, of: String, required: true, comment: "存储桶标签" },
  mode: {
    type: String,
    required: true,
    enum: Object.values(BucketMode),
    comment: "存储桶模式",
  },
  path: { type: String, required: false, comment: "存储桶路径(可选)" },
  versions: [
    {
      versionId: { type: String, required: true },
      path: { type: String, required: true },
    },
  ],
};

class BucketModel extends BaseModel<IBucket> {
  constructor() {
    super("Bucket", bucketSchemaDefinition, { timestamps: true });
  }

  // 创建存储桶时自动生成路径(如果未指定)
  async create(
    doc: Partial<IBucket>,
    userId?: mongoose.Types.ObjectId | null
  ): Promise<IBucket> {
    if (!doc.bucketName) {
      throw new Error("Bucket name is required.");
    }

    if (!doc.path) {
      // 使用 UUID 确保路径唯一性
      const uniqueId = uuidv4();
      doc.path = path.join(basePath, uniqueId);
    }
    // 确保路径唯一性和有效性
    doc.path = path.normalize(doc.path);

    // 在路径生成之后,可以添加逻辑检查路径是否已经存在
    const existingBucket = await this.getModel().findOne({ path: doc.path });
    if (existingBucket) {
      throw new Error(
        "Path already exists, please choose a different bucket name or path."
      );
    }

    // 创建存储桶
    const bucket = await super.create(doc, userId);

    // 根据模式执行不同的操作
    if (doc.mode === BucketMode.LOCAL_RESOURCES) {
      // 确保 bucket._id 是 mongoose.Types.ObjectId 类型
      const bucketId = bucket._id as mongoose.Types.ObjectId;
      // 启动异步任务扫描本地目录并更新到 objectmetadatas 表中
      scanLocalDirectoryAndUpdateMetadata(doc.path, bucketId);
    }

    return bucket;
  }
}

export default new BucketModel();

实现扫描目录和保存元数据的功能

我们需要实现一个工具函数来扫描指定的目录,并将文件信息保存到 ObjectMetadata 表中。

文件路径: src/utils/scanDirectory.ts

import fs from "fs";
import path from "path";
import crypto from "crypto";
import ObjectMetadataModel from "../models/FileModel";
import mongoose from "mongoose";

// 计算文件的 MD5 哈希值
export const calculateFileMd5 = (filePath: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    const hash = crypto.createHash("md5");
    const stream = fs.createReadStream(filePath);

    stream.on("data", (data) => hash.update(data));
    stream.on("end", () => resolve(hash.digest("hex")));
    stream.on("error", (err) => reject(err));
  });
};

// 扫描本地目录并更新到 objectmetadatas 表中
export const scanLocalDirectoryAndUpdateMetadata = async (
  dirPath: string,
  bucketId: mongoose.Types.ObjectId
) => {
  try {
    const files = fs.readdirSync(dirPath);

    for (const file of files) {
      const filePath = path.join(dirPath, file);
      const stats = fs.statSync(filePath);

      if (stats.isFile()) {
        const fileMd5 = await calculateFileMd5(filePath);

        const fileInfo = {
          originalFileName: file,
          storageFileName: file,
          filePath: filePath,
          fileSize: stats.size,
          fileType: path.extname(file),
          fileMd5: fileMd5, // 计算并更新 MD5
          bucketId: bucketId,
        };

        await ObjectMetadataModel.create(fileInfo);
      } else if (stats.isDirectory()) {
        // 递归扫描子目录
        await scanLocalDirectoryAndUpdateMetadata(filePath, bucketId);
      }
    }
  } catch (error) {
    console.error("Error scanning local directory:", error);
  }
};

实现前端项目托管

什么是静态文件托管?

静态文件托管是一种服务,允许用户将静态资源(如HTML、CSS、JavaScript文件)上传到服务器,并通过URL进行访问。这种服务通常用于托管前端项目、网站静态资源等。

静态文件托管的作用

  • 网站托管:用户可以将前端项目上传到存储系统,并通过网络访问,实现网站托管。
  • 资源分发:通过静态文件托管,用户可以将静态资源分发给全球用户,提升资源访问速度。
  • 数据备份:用户可以将重要的静态资源备份到存储系统,确保数据安全。

实现静态文件托管

为了实现静态文件托管功能,我们需要完成以下几个步骤:

  1. 更新存储桶模型:支持静态文件托管模式。
  2. 实现文件上传和下载接口:允许用户上传和下载静态文件。
  3. 配置静态文件路由:配置路由,使得用户可以通过URL访问静态文件。

我们将继续保持控制器和路由分离的结构。在这种情况下,我们需要在控制器中实现相应的逻辑,并在路由文件中配置这些控制器。

创建 src/controllers/bucket/hostingController.ts

这里拆分了bucketController.ts,改成了目录。目前bucket下面有两个文件bucketController.tshostingController.ts

/* eslint-disable @typescript-eslint/no-explicit-any */
import { Request, Response } from "express";
import path from "path";
import fs from "fs";
import BucketModel, { BucketMode } from "../../models/BucketModel";
import mongoose from "mongoose";

// 静态文件托管控制器
export const serveStaticFile = async (req: Request, res: Response) => {
  console.log("serveStaticFile", req.params);
  const { bucketName } = req.params;
  const filePath = req.params[0];
  console.log(filePath, bucketName);
  try {
    const bucket = await BucketModel.findOne({
      _id: new mongoose.Types.ObjectId(bucketName),
    });

    if (!bucket || bucket.mode !== BucketMode.FRONTEND_HOSTING) {
      return res
        .status(404)
        .json({ message: "Bucket not found or not in hosting mode" });
    }

    // 获取最新版本路径
    const latestVersionPath = await BucketModel.getLatestVersionPath(
      bucket._id as mongoose.Types.ObjectId
    );

    if (!latestVersionPath) {
      return res
        .status(404)
        .json({ message: "No versions found for this bucket" });
    }

    let fullPath: string;

    if (!filePath) {
      // 如果 filePath 为空,返回 index.html
      fullPath = path.join(latestVersionPath, "index.html");
    } else {
      // 否则,返回请求的文件
      fullPath = path.join(latestVersionPath, filePath);
    }
    console.log([bucket.path, fullPath].join("/"));
    if (!fs.existsSync([bucket.path, fullPath].join("/"))) {
      return res.status(404).json({ message: "File not found" });
    }

    res.sendFile([bucket.path, fullPath].join("/"));
  } catch (error: any) {
    res
      .status(500)
      .json({ message: "Error serving file", error: error.message });
  }
};

更新 bucketRoutes.ts

import { Router } from "express";
import { serveStaticFile } from "../controllers/bucket/hostingController";

const router = Router();

// 处理静态文件托管请求
router.get("/hosting/:bucketName/*", serveStaticFile);

export default router;

通过这些步骤,我们已经成功实现了前端项目/静态文件托管功能。用户可以创建一个存储桶并设置其模式为 FRONTEND_HOSTING,然后将静态文件上传到该存储桶。通过访问 /api/bucket/hosting/:bucketName/* 路由,用户可以直接从存储桶中获取静态文件。

这里的地址是临时的,后面还会实现一个绑定host的

那现在这个就已经满足基本需求了吗?答案是否定的还差一个上传代码的功能。

有老哥肯定会问,用普通的桶的上传不行吗?No!No!No!

为什么需要专门的上传接口?

在对象存储系统中,前端项目托管和普通文件存储有着不同的需求和使用场景。下面通过几个关键点来解释为什么需要专门的上传接口。

  1. 版本管理
    • 前端项目托管:前端项目通常需要频繁更新和发布新版本。每次发布新版本时,用户希望能够快速回滚到旧版本,或者在不同版本之间切换。因此,每次上传都需要生成一个唯一的版本 UUID,并在访问时自动使用最新的版本。
    • 普通文件存储:普通文件存储通常不需要频繁的版本管理,只需要简单的文件上传和下载功能即可。
  2. 批量上传和解压缩
    • 前端项目托管:前端项目通常由多个文件组成(HTML、CSS、JavaScript等),这些文件需要一起上传并解压缩到指定目录。专门的上传接口可以处理压缩包文件的上传和解压缩,确保项目文件的完整性和一致性。
    • 普通文件存储:普通文件存储更多的是单个文件的上传和管理,不需要处理压缩包和解压缩的逻辑。
  3. 路径管理
    • 前端项目托管:为了实现版本管理,每个版本的文件需要存储在独立的目录中。专门的上传接口可以自动为每个版本生成独立的路径,并将文件存储到相应的目录中。
    • 普通文件存储:普通文件存储一般不需要复杂的路径管理,只需要将文件存储到指定的路径即可。
  4. 访问控制
    • 前端项目托管:前端项目托管需要提供统一的访问入口,通过 /api/bucket/hosting/:bucketName/* 路由访问最新版本的文件。专门的上传接口可以确保文件上传后能够正确配置访问路径。
    • 普通文件存储:普通文件存储的访问控制相对简单,只需要提供基本的文件下载接口即可。

接下来我们可以设计两个专门的接口来处理前端项目文件上传和普通的多文件批量上传,同时引入版本管理的概念。每次上传都会生成一个新的版本 UUID,并在访问时自动使用最新的版本。以下是详细的设计思路和代码实现:

  1. 前端项目文件上传接口:支持上传压缩包(如 ZIP 文件),解压后保存到存储桶并生成新的版本。
  2. 普通多文件批量上传接口:支持一次性上传多个文件,保存到存储桶并生成新的版本。
  3. 版本管理:每次上传都会生成新的版本 UUID,存储在数据库中。访问时自动使用最新的版本。

更新存储桶模型

我们需要在存储桶模型中添加版本管理的字段。

import mongoose from "mongoose";
import BaseModel, { IBaseDocument } from "./BaseModel";
import path from "path";
import { v4 as uuidv4 } from "uuid";
import { scanLocalDirectoryAndUpdateMetadata } from "../utils/scanDirectory";

// 定义枚举
export enum BucketMode {
  STANDARD = "Standard",
  FRONTEND_HOSTING = "FrontendHosting",
  LOCAL_RESOURCES = "LocalResources",
}

const basePath = "/media/plyue/662C8A672C8A31DB6/ore";

// 定义存储桶接口
export interface IBucket extends IBaseDocument {
  bucketName: string; // 存储桶名称
  accessControl: string; // 访问控制设置
  quota: number; // 存储配额
  encryptionSettings: {
    enabled: boolean; // 是否启用加密
    algorithm?: string; // 加密算法(可选)
  };
  loggingConfiguration: {
    enabled: boolean; // 是否启用日志记录
    targetBucket?: string; // 日志目标存储桶(可选)
    targetPrefix?: string; // 日志目标前缀(可选)
  };
  tags: Record<string, string>; // 存储桶标签
  mode: BucketMode; // 存储桶模式
  path?: string; // 存储桶路径(可选)
  versions: { versionId: string; path: string }[]; // 版本管理
}

// 定义存储桶模式的 Schema
const bucketSchemaDefinition: mongoose.SchemaDefinition = {
  bucketName: {
    type: String,
    required: true,
    unique: true,
    comment: "存储桶名称",
  },
  accessControl: { type: String, required: true, comment: "访问控制设置" },
  quota: { type: Number, required: true, comment: "存储配额" },
  encryptionSettings: {
    enabled: { type: Boolean, required: true, comment: "是否启用加密" },
    algorithm: { type: String, required: false, comment: "加密算法(可选)" },
  },
  loggingConfiguration: {
    enabled: { type: Boolean, required: true, comment: "是否启用日志记录" },
    targetBucket: {
      type: String,
      required: false,
      comment: "日志目标存储桶(可选)",
    },
    targetPrefix: {
      type: String,
      required: false,
      comment: "日志目标前缀(可选)",
    },
  },
  tags: { type: Map, of: String, required: true, comment: "存储桶标签" },
  mode: {
    type: String,
    required: true,
    enum: Object.values(BucketMode),
    comment: "存储桶模式",
  },
  path: { type: String, required: false, comment: "存储桶路径(可选)" },
  versions: [
    {
      versionId: { type: String, required: true },
      path: { type: String, required: true },
    },
  ],
};

class BucketModel extends BaseModel<IBucket> {
  constructor() {
    super("Bucket", bucketSchemaDefinition, { timestamps: true });
  }

  // 创建存储桶时自动生成路径(如果未指定)
  async create(
    doc: Partial<IBucket>,
    userId?: mongoose.Types.ObjectId | null
  ): Promise<IBucket> {
    if (!doc.bucketName) {
      throw new Error("Bucket name is required.");
    }

    if (!doc.path) {
      // 使用 UUID 确保路径唯一性
      const uniqueId = uuidv4();
      doc.path = path.join(basePath, uniqueId);
    }
    // 确保路径唯一性和有效性
    doc.path = path.normalize(doc.path);

    // 在路径生成之后,可以添加逻辑检查路径是否已经存在
    const existingBucket = await this.getModel().findOne({ path: doc.path });
    if (existingBucket) {
      throw new Error(
        "Path already exists, please choose a different bucket name or path."
      );
    }

    // 创建存储桶
    const bucket = await super.create(doc, userId);

    // 根据模式执行不同的操作
    if (doc.mode === BucketMode.LOCAL_RESOURCES) {
      // 确保 bucket._id 是 mongoose.Types.ObjectId 类型
      const bucketId = bucket._id as mongoose.Types.ObjectId;
      // 启动异步任务扫描本地目录并更新到 objectmetadatas 表中
      scanLocalDirectoryAndUpdateMetadata(doc.path, bucketId);
    }

    return bucket;
  }

  // 添加新版本
  async addVersion(bucketId: mongoose.Types.ObjectId, versionPath: string) {
    const versionId = uuidv4();
    const bucket = await this.getModel().findById(bucketId);
    if (!bucket) throw new Error("Bucket not found");

    bucket.versions.push({ versionId, path: versionPath });
    await bucket.save();

    return versionId;
  }

  // 获取最新版本路径
  async getLatestVersionPath(bucketId: mongoose.Types.ObjectId) {
    const bucket = await this.getModel().findById(bucketId);
    if (!bucket) throw new Error("Bucket not found");

    const latestVersion = bucket.versions[bucket.versions.length - 1];
    return latestVersion ? latestVersion.path : null;
  }
}

export default new BucketModel();

实现文件上传接口

我们需要实现两个接口:一个用于上传压缩包文件,另一个用于普通多文件批量上传。

创建 src/controllers/bucket/uploadController.ts

/* eslint-disable @typescript-eslint/no-explicit-any */
import { Request, Response } from "express";
import path from "path";
import fs from "fs";
import multer from "multer";
import { v4 as uuidv4 } from "uuid";
import BucketModel, { BucketMode } from "../../models/BucketModel";
import { extractZip } from "../../utils/extractZip";
import mongoose from "mongoose";

const upload = multer({ dest: "uploads/" });

// 上传压缩包文件并生成新版本
export const uploadZipFile = [
  upload.single("file"),
  async (req: Request, res: Response) => {
    const { bucketName } = req.params;
    const file = req.file;

    if (!file) {
      return res.status(400).json({ message: "No file uploaded" });
    }

    try {
      const bucket = await BucketModel.findOne({
        _id: new mongoose.Types.ObjectId(bucketName as string),
      });
      console.log(bucket);
      if (!bucket || bucket.mode !== BucketMode.FRONTEND_HOSTING) {
        return res
          .status(404)
          .json({ message: "Bucket not found or not in hosting mode" });
      }

      const version = uuidv4();
      const versionPath = path.join(bucket.path as string, version);

      // 创建版本目录
      fs.mkdirSync(versionPath, { recursive: true });

      // 解压文件到版本目录
      await extractZip(file.path, versionPath);

      // 更新存储桶版本
      await BucketModel.addVersion(
        bucket._id as mongoose.Types.ObjectId,
        version
      );

      res
        .status(200)
        .json({ message: "File uploaded and version created", version });
    } catch (error: any) {
      res
        .status(500)
        .json({ message: "Error uploading file", error: error.message });
    } finally {
      // 删除临时上传的文件
      fs.unlinkSync(file.path);
    }
  },
];

// 上传多个文件并生成新版本
export const uploadMultipleFiles = [
  upload.array("files"),
  async (req: Request, res: Response) => {
    const { bucketName } = req.params;
    const files = req.files as Express.Multer.File[];

    if (!files || files.length === 0) {
      return res.status(400).json({ message: "No files uploaded" });
    }

    try {
      const bucket = await BucketModel.findOne({
        _id: new mongoose.Types.ObjectId(bucketName as string),
      });

      if (!bucket || bucket.mode !== BucketMode.FRONTEND_HOSTING) {
        return res
          .status(404)
          .json({ message: "Bucket not found or not in hosting mode" });
      }

      const version = uuidv4();
      const versionPath = path.join(bucket.path as string, version);

      // 创建版本目录
      fs.mkdirSync(versionPath, { recursive: true });

      // 保存上传的文件到版本目录
      for (const file of files) {
        const destPath = path.join(versionPath, file.originalname);
        fs.renameSync(file.path, destPath);
      }

      // 更新存储桶版本
      await BucketModel.addVersion(
        bucket._id as mongoose.Types.ObjectId,
        version
      );

      res
        .status(200)
        .json({ message: "Files uploaded and version created", version });
    } catch (error: any) {
      res
        .status(500)
        .json({ message: "Error uploading files", error: error.message });
    }
  },
];

创建 src/utils/extractZip.ts

安装unzipper

npm i unzipper
npm i --save-dev @types/unzipper

为了解压 ZIP 文件,我们需要实现一个辅助工具函数 extractZip

import fs from "fs";
import path from "path";
import unzipper from "unzipper";

// 解压 ZIP 文件到指定目录
export const extractZip = (zipFilePath: string, destDir: string): Promise<void> => {
  return new Promise((resolve, reject) => {
    fs.createReadStream(zipFilePath)
      .pipe(unzipper.Extract({ path: destDir }))
      .on('close', resolve)
      .on('error', reject);
  });
};

更新路由文件

我们需要在 bucketRoutes.ts 文件中添加新的上传路由。

更新 bucketRoutes.ts

import { Router } from "express";
import { serveStaticFile } from "../controllers/bucket/hostingController";
import { uploadZipFile, uploadMultipleFiles } from "../controllers/bucket/uploadController";

const router = Router();

// 处理静态文件托管请求
router.get("/hosting/:bucketName/*", serveStaticFile);
router.get("/hosting/:bucketName", serveStaticFile)

// 上传压缩包文件并生成新版本
router.post("/:bucketName/upload-zip", uploadZipFile);

// 上传多个文件并生成新版本
router.post("/:bucketName/upload-files", uploadMultipleFiles);

export default router;

测试与验证

到此为止,我们已经实现了前端项目托管和本地资源开放网络访问的功能。接下来,我们需要对这些功能进行测试和验证。

  1. 创建存储桶:创建一个存储桶并设置其模式为 FRONTEND_HOSTINGLOCAL_RESOURCES
  2. 上传文件:通过上传接口上传压缩包或多个文件到存储桶,并生成新的版本。
  3. 访问文件:通过静态文件托管路由访问上传的文件,确保文件可以正确加载。

总结

本章介绍了如何在对象存储系统中实现前端项目托管和本地资源开放网络访问的功能。通过使用 Node.js、Express、Mongoose、Multer 等工具和库,我们实现了文件上传、版本管理、静态文件访问和本地目录扫描等功能。读者现在能够通过这些技术实现存储桶管理和静态资源访问的功能,为其存储系统增添更多灵活性和功能性。