landscape photography of clouds

Next体验:与Hono.js结合再进化

Vercel云函数的缺点和我的改进方案

上一节我们做了一个基础的文件操作服务,使用Next + Vercel部署。我通过后续文档发现,Vercel的云函数有一些限制:

  • 临时文件访问限制。过期会自动删除,这个倒是没关系,因为文件转换器本来就是删掉临时文件的
  • 依赖包的限制。sharp 等文件操作库依赖原生模块(Native Addons),导致编译不通过
  • 边缘计算(Edge)的限制。Edge Runtime 完全禁用 fspath 等模块,仅支持有限的 Web API,文件操作需要进行流式处理重构

那么这种情况下利用Vercel的云函数做Serverless就不再合适了。

当下流行的方案有 Supabase ,Supabse是一款开源的后端即服务(Baas)、提供完整的数据库能力(基于Postgresql)、开箱即用的后端服务(OAuth、对象存储)、AI应用协同。作为初创企业是不错的。

这里我使用的是Hono.js作为后端服务,Hono.js可以集成多种运行时(Bun、Deno、Vercel等),在设计上注重极简主义(这点我很喜欢😊),工程上注重边缘计算、Serverless等实现。

 

集成Hono.js和结构分层

决定了替代方案之后,我们的文件目录也需要调整,以便于后续开发

这里我们重点关注 src 、 server 、messages 三个结构目录;因为不用Vercel提供的服务器了,所以我们也需要准备Docker和Nginx在自己的云服务器上,这节我们先不讲部署相关的内容。

  • server 后端接口
  • src Next前端页面(同原来)
  • messages(i18n多语言配置,用来存放语言文件)

接下来看一下server的结构目录吧

三个目录的作用如下

  • core 核心代码,services同大多数后端架构一样,存放业务逻辑
  • hono 存放路由、中间件过滤器等等
  • utils 存放工具函数

接下来我贴一个粗略的网络请求流程图,后端的同学对这个很熟悉,可以酌情跳过,后续讲解从流程图的顺序开始描述对应的文件

“接收请求”“路由和校验器”这个步骤是在/server/hono/app.ts 下进行的,接收到了请求之后,配合middleware进行鉴权、Validate等处理;这里我为了简化业务流程,middleware文件夹先空着;

app.ts是后端服务的起点(这里和src路径下的前端app.ts不一样,虽然会同名,但是不是同一个东西)

app我增加了一个.prod的文件,适用于在生产环境下启用的。

// 生产环境启动文件 app.prod.ts
import { Hono } from "hono";
import converter from "./routes/image-converter/index";
import { cors } from "hono/cors";
import hello from "./routes/hello/index";
import cleanup from "./routes/cleanup/index";
import { serve } from "@hono/node-server";
import { CleanupService } from "@server/core/services/cleanup.service";
const app = new Hono().basePath("/api");
app.use("*", cors());

app.route("/converter", converter);
app.route("/hello", hello);
app.route("/cleanup", cleanup);

app.get("/health", (c) => c.text("OK"));

const port = Number.parseInt(process.env.HONO_PORT || "3001", 10);

const server = serve(
	{
		fetch: app.fetch,
		port,
		hostname: "0.0.0.0",
	},
	(info) => {
		console.log(`Hono服务运行在 http://0.0.0.0:${info.port}`);

		// 启动定时清理服务
		const cleanupService = new CleanupService();
		cleanupService.startCleanupService();
	},
);

// 优雅关闭
const gracefulShutdown = (signal: string) => {
	console.log(`收到 ${signal} 信号,开始优雅关闭...`);
	server.close(() => {
		console.log("服务器已关闭");
		process.exit(0);
	});
};

process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));

export default {
	port,
	fetch: app.fetch,
};

10-33行:已经启动了服务了,已经能够根据路由进行匹配,如代码所示,会寻找routes/下的路由,Hono里控制层不会进行明显的分层,简化了步骤。

30-31行:服务启动之后,还启动了一个定时任务的接口,主要是由于用户上传文件之后,会残留临时文件,我们需要把他定期清理掉。

36-45行:优雅启停,如碰上意外情况关闭服务时,不会一下子“秒挂”服务,会依次处理相关任务,好让进程有所反应。

以上已经完成了文件解耦的工作,移动到routes目录,接下来去完成具体的业务实现:

业务逻辑的实现

coverter的路由跟上一节我进行了变动,我重新整理了需求,上一节我只限制webp转换img格式,格式是固定的,现在我们的需求改为特定的四种格式png、jpg、jpeg、webp转换为特定的三种格式jpg、png、webp。我想这几种格式已经覆盖大部分的需求了。

 

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { ImageConverterService } from "../../../core/services/image-converter.service";
import { z } from "zod";

const converterRouter = new Hono();
const converterService = new ImageConverterService();
// 支持的格式
const allowedInputTypes = [
  "image/png",
  "image/jpeg",
  "image/jpg",
  "image/webp",
];
const allowedTargetFormats = ["jpg", "png", "webp"];
// 定义 Zod 验证 schema
const schema = z.object({
  file: z
    .instanceof(File, { message: "请上传有效文件" })
    .refine((f) => allowedInputTypes.includes(f.type), "仅支持png、jpg、jpeg、webp格式")
    .refine((f) => f.size < 5 * 1024 * 1024, "文件大小不能超过5MB"),
  targetFormat: z.string().refine((val) => allowedTargetFormats.includes(val), "目标格式不合法"),
});

converterRouter.post(
  "/",
  zValidator("form", schema),
  async (c) => {
    const { file, targetFormat } = c.req.valid("form");
    try {
      const buffer = Buffer.from(await file.arrayBuffer());
      const convertedUrl = await converterService.convert(buffer, targetFormat as "jpg" | "png" | "webp");
      return c.json({
        original: file.name,
        convertedUrl,
      });
    } catch (error) {
      return c.json({ err: error instanceof Error ? error.message : "文件处理失败" }, 500);
    }
  },
);
export default converterRouter;

17-23行:使用了zod作为校验器,hono的风格是定义一个schema作为对象来校验。我们没有通用的校验器,但是单独自定义的校验还是必要的。

25-40行:常规的post接口处理,32行访问了对应的service业务处理。

接下来做删除文件的route:

import { Hono } from "hono";
import { CleanupService } from "../../../core/services/cleanup.service";
import { success } from "zod/v4";
const cleanupService = new CleanupService();
const cleanupRouter = new Hono();
// 启动定时清理
cleanupRouter.post("/start", (c) => {
	try {
		cleanupService.startCleanupService();
		return c.json({ message: "Cleanup service started", success: true }, 200);
	} catch (error) {
		return c.json(
			{ message: "Cleanup service failed to start", success: false },
			500,
		);
	}
});
cleanupRouter.get("/status", (c) => {
	try {
		cleanupService.startCleanupService();
		return c.json({ message: "Cleanup service started", success: true }, 200);
	} catch (error) {
		return c.json(
			{ message: "Cleanup service failed to get status", success: false },
			500,
		);
	}
});
// 停止
cleanupRouter.post("/stop", (c) => {
	try {
		cleanupService.stopCleanupService();
		return c.json({ message: "Cleanup service stopped", success: true }, 200);
	} catch (error) {
		return c.json(
			{ message: "Cleanup service failed to stop", success: false },
			500,
		);
	}
});
// 手动触发
cleanupRouter.post("/manual", (c) => {
	try {
		cleanupService.manualCleanup();
		return c.json({ message: "Cleanup service triggered", success: true }, 200);
	} catch (error) {
		return c.json(
			{ message: "Cleanup service failed to trigger", success: false },
			500,
		);
	}
});
export default cleanupRouter;

清理的代码就比较公式化了,含常规的启停,这里返回格式还可以进一步的封装,中大型项目还需要额外实现。

接下来我们进入services,移动到core目录下:

 

image-converter和上一节内容有所差异,主要是扩展了多种格式。

import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import sharp from "sharp";
import path from "node:path";
export class ImageConverterService {
	private readonly outputDir = path.join(process.cwd(), "public/converted");
	constructor() {
		// 确保输出目录存在
		fs.mkdir(this.outputDir, { recursive: true });
	}
	/**
	 * 通用图片格式转换
	 * @param fileBuffer 上传文件的buffer
	 * @param targetFormat 目标格式 jpg/png/webp
	 * @returns 转换后的文件路径,相对于public
	 */
	async convert(fileBuffer: Buffer, targetFormat: "jpg" | "png" | "webp"): Promise<string> {
		try {
			const ext = targetFormat === "jpg" ? "jpg" : targetFormat;
			const outputFilename = `${randomUUID()}.${ext}`;
			const outputPath = path.join(this.outputDir, outputFilename);
			let sharpInstance = sharp(fileBuffer);
			if (targetFormat === "jpg") {
				sharpInstance = sharpInstance.jpeg({ quality: 80 });
			} else if (targetFormat === "png") {
				sharpInstance = sharpInstance.png({ quality: 80 });
			} else if (targetFormat === "webp") {
				sharpInstance = sharpInstance.webp({ quality: 80 });
			} else {
				throw new Error("不支持的目标格式");
			}
			await sharpInstance.toFile(outputPath);
			return `/converted/${outputFilename}`;
		} catch (err) {
			throw new Error(
				`convert failed: ${err instanceof Error ? err.message : String(err)}`,
			);
		}
	}
    /**
   * 清理临时文件
   */
  async cleanup(fileUrl: string) {
    const filename = path.basename(fileUrl);
		const flePath = path.join(this.outputDir, filename);
    try {
			await fs.access(flePath);
			await fs.unlink(flePath);
		} catch (error) {
			console.error(`delete file failed: ${error}`);
		}
  }
	/** 清理已转换的文件 */
	async cleanupConvertedFiles() {
		try {
			const files = await fs.readdir(this.outputDir);
			const convertedFiles = files.filter((file) => file.endsWith(".converted.jpg"));
			for (const file of convertedFiles) {
				const filePath = path.join(this.outputDir,file);
				try {
					await fs.unlink(filePath)
				} catch (error) {
					console.error(`delete file failed: ${error}`);
				}
			}
		} catch (error) {
			console.error(`cleanup converted files failed: ${error}`);
		}
	}
	/** 获取已转换文件的数量 */
	async getConvertedFilesCount() :Promise<number>{
		try {
			const files = await fs.readdir(this.outputDir);
			return files.filter((file) => file.endsWith(".converted.jpg")).length;
		} catch (error) {
			console.error(`get converted files count failed: ${error}`);
			return 0;
		}
	}
}

这里的大体思路是将上传的文件转换之后,形成的临时文件加一个标记,或者移动到临时文件路径下,再通过cleanup服务定期清理。

这里讲个题外话,现在AI能力很强,大多数的业务代码AI都能做,我的文章讲一些产品、设计、架构上的思路,这是由于AI的编码能力很强大,但是对于一些噪音和具体的需求、产品思维的实现实际是欠缺的,现在市面上诞生了提示词工程师,专注于优化AI的提示词;如果我们从系统观念的角度出发思考,辅以AI工具,我认为是一个有效的转型方向。

继续实现最后一步的清理文件业务:

import { ImageConverterService } from "./image-converter.service";

export class CleanupService {
	private readonly imageConverterService: ImageConverterService;
	private cleanupInterval: NodeJS.Timeout | null = null;
	private readonly CLEANUP_INTERVAL_MS = 1000 * 60 * 60 * 24; // 24小时
	private readonly MAX_FILE_AGE = 2 * 60 * 60 * 1000; // 2小时
	constructor() {
		this.imageConverterService = new ImageConverterService();
	}
	/**
	 * 启动定时清理服务
	 */
	startCleanupService() {
		console.log("start cleanup service");
		this.performCleanup();
		// 设置定期清理任务
		this.cleanupInterval = setInterval(() => {
			this.performCleanup();
		}, this.CLEANUP_INTERVAL_MS);
		console.log(
			`cleanup service started, cleanup interval: ${this.CLEANUP_INTERVAL_MS}ms`,
		);
	}
	/**
	 * 清理操作
	 */
	private async performCleanup() {
		try {
			const beforeCount =
				await this.imageConverterService.getConvertedFilesCount();
			if (beforeCount > 0) {
				await this.imageConverterService.cleanupConvertedFiles();
				const afterCount =
					await this.imageConverterService.getConvertedFilesCount();
				console.log(
					`cleanup completed, before: ${beforeCount}, after: ${afterCount}`,
				);
			} else {
				console.log("no converted files to cleanup");
			}
		} catch (error) {
			console.error(`cleanup failed: ${error}`);
		}
	}
	/**
	 * 停止定时清理服务
	 */
	stopCleanupService() {
		if (this.cleanupInterval) {
			clearInterval(this.cleanupInterval);
			this.cleanupInterval = null;
			console.log("cleanup service stopped");
		}
	}
	/**
	 * 手动清理
	 */
	async manualCleanup() {
		try {
			await this.performCleanup();
		} catch (error) {
			console.error(`manual cleanup failed: ${error}`);
		}
	}
  getStatus(){
    return{
      isRunning: this.cleanupInterval !== null,
      interval: this.CLEANUP_INTERVAL_MS,
      nextCleanup: this.cleanupInterval ? new Date(Date.now() + this.CLEANUP_INTERVAL_MS).toISOString() : null,
    }
  }
}

同样的,业务方面也不复杂,调用的文件操作已经实现过了,正常调用即可。

总结

今天这个重构过程大多数代码都是用AI实现的,正确率和效率都还是不错,我在其中担任的工作是以工程化的思想让AI打工。

当下的IT技术发展,工程化、计算机基础、架构思维,越来越重要,这对新人来说是一种考验,因为这些能力本就是在实践中锻炼,没有这方面的阅历容易陷入夜郎自大的陷阱里。

同时对我们这种 “老人”也是一种考验,若是面向独立开发、面向未来,高效能利用AI是很有必要的,这段时间我经常看别人的创业故事和独立开发日记,那些具有产品思维的开发或者具有开发能力的产品,在这样的AI浪潮下勇立潮头。

而传统的💩代码由于其历史的业务原因,倾向于用外包或者现有工程师维护,他们的精力成长性正在逐步损耗,在AI的效率革命之下,可能会受到一定冲击,而这在短期内可能不会看到。

这一节解耦之后,下一节在前端层面加入i18n的配置,因为我们的工具站以英文站为主。现在Vercel的路径能访问,但是功能受限,后续完成私有服务器部署之后再贴地址。代码的远程仓库如下Hono.js实现文件转换

感谢阅读,拜拜~

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注