开发过程中,肯定会用到上传文件功能,这里目前就讨论两种情况,分别是
直接使用文件系统保存到磁盘(常见)
、
文件存储服务库(minio)
还有其他存储服务的都可以类推,其中
minio 服务和 mysql
一样,都是需要下载配置的,算是用的很多的一个服务了(并且还支持远端购买空间,一般都是用自己服务器吧😅)
minio 地址
上传到磁盘
这里的磁盘默认就是在本地,如果文件服务器设置到另一个大储存空间那端,那么另一端也可以像这样配置,只不过就是需要其他服务器间接调用,甚至可以直接调用都可以
使用前,我们先安装文件库
yarn add multer @nestjs/platform-express
yarn add @types/multer --dev
yarn add dayjs
创建 file 模块,用于我们编写功能,我们仍然是使用 res 创建几个常用文件
nest g res file
ps
: File 库会跟系统的一些库重名,引用时额外注意位置,有必要的话改个名字(例如: store、diskfile、filestore都行)
如下所示已创建完毕了
我们进入 .file.module
中添加内容, @Module
中 import
我们的数据库 File
,同时如下所示,注册 MulterModule
模块,注册后,每次上传文件都会走到下面的 filename 后买你的回调
这是我们下载用的操作,基本都在这里了,设置目录、文件名字
等等
import dayjs = require('dayjs');
imports: [
TypeOrmModule.forFeature([File]),
MulterModule.register({
storage: diskStorage({
destination: `./public/uploads/${dayjs().format('YYYY-MM-DD')}`,
filename: (req, file, cb) => {
let ext = file.originalname.split('.').at(-1)
let filename = `${new Date().getTime()}.${ext ? ext : ''}`
return cb(null, filename);
controller 中编写我们的接口,当然实际逻辑应当到我们的 service 中编写,controller 永远做分发功能,也方便我们理清逻辑
另外,前面编写完毕后,到这里获取 file 时,实际上已经保存到磁盘了,当然如果文件为空,是不会走上面的保存回调的,我们这里获取到的 file 也会是空
上传单个文件
@Post('file')
@UseInterceptors(FileInterceptor('file'))
uploadFiles(
@UploadedFile() file: Express.Multer.File,
console.log(files);
@Post('file')
@UseInterceptors(FilesInterceptor('file'))
uploadFiles(
@UploadedFiles() files: Array<Express.Multer.File>,
console.log(files);
@Post('file')
@UseInterceptors(AnyFilesInterceptor(file))
uploadFile(
@Body() objDto: ObjDto,
@UploadedFiles() files: Array<Express.Multer.File>,
console.log(files);
console.log(objDto)
实际使用的最多的是单个文件上传,其他的都可以被代替(大不了调用多个接口是吧)
到这里还没结束,file 中保存的 path 等,默认应当拼接我们的路由前缀就可以访问才对,这里实际上不能直接访问,还需要我们配置,也就是授权的目录才可以直接访问
在我们的 main 中添加目录,这里是设置 public 可以随意访问,从下面可以看出与我们的项目并列,注意部署多个项目时别被其他项目的窜到一个文件夹了
import * as express from 'express';
app.use('/public', express.static(`${__dirname}/../public`));
文件与数据库问答
有了文件为什么还要创建数据库表格存放到里面
文件存放后,我们要保存它的路径,后续才能直接访问到他,否则,他就成了无主之物一般,我们无法知道他的位置,也无法访问了,另外我们的数据库除了保存文件的基础信息,我们还会被关联到相应的人,一个人可以发布多张图,一张图可以被多个人点赞、收藏、设置使用等等
数据库存放文件
不多介绍了,保存内容,方便使用,顺便引出文档
async upload(
mFile: Express.Multer.File,
user: User,
if (!mFile) {
return ResponseData.fail('请选择文件');
console.log(mFile)
let file = new File()
file.originalname = mFile.originalname;
file.mimetype = mFile.mimetype;
file.size = mFile.size;
file.path = mFile.path;
file.user = user;
await this.fileRepository.save(file)
return ResponseData.ok(file)
文件查看/下载
我们都上传了,我们怎么下载呢,我们一般都是用url来访问某个图片,实际上走的就是我们的 get 请求,我们只需要写一个 get 读取文件内容给用户就行了
import { Response } from 'express'
import { join } from 'path'
@ApiOperation({
summary: '获取单个文件信息',
@Get()
getFile(@Query('path') path: string, @Res() res: Response) {
res.download(join(__dirname, `../../${path}`))
而给用户返回的url我们要拼接好了,不然客户端不能直接用,那就惨了
export function getFileUrl(file: File) {
return `http://${env.config.HOST}:${env.config.PORT}/api/file?path=${file.path}`
file
的 originalname
乱码问题
上传过程中,可能会出现需要用到原文件名称的问题,Express.Multer.File
默认返回的 originalname 就是一个乱码,因此要是保存到数据库和返回都是乱码,怎么办呢,通过 buffer 转化后在保存
...file: Express.Multer.File
Buffer.from(file.originalname, "latin1").toString("utf8")
上面只介绍了怎么用,但是却没有配置文档,下面配置一下参数文档(返回的前面有介绍,就不介绍了)
@ApiOperation({
summary: '上传文件到磁盘'
@ApiConsumes('multipart/form-data')
ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
upload(
@UploadedFile() file: Express.Multer.File,
@ReqUser() user: User,
return this.fileService.upload(file, user);
上面的两个文档参数设置很不方便,如果有多个上传就比较臃肿了,我们可以封装一下,新建一个装饰器,创建为 file.decorator.ts
,在里面设置一下
import { ApiBody, ApiConsumes } from "@nestjs/swagger"
export const ApiFileConsumes = () => ApiConsumes('multipart/form-data')
export const ApiFileBody = () => ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
简化后的装饰器配置
@ApiOperation({
summary: '上传文件到磁盘'
@ApiFileConsumes()
@ApiFileBody()
@Public()
@APIResponse()
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
upload(
@UploadedFile() file: Express.Multer.File,
@ReqUser() user: User,
return this.fileService.upload(file, user);
便利性操作 afterLoad
通过 afterLoad
装饰器可以让我们查询 entity 后,自动调用 afterLoad
后的方法 给 url 赋值,这样返回时就不用每次都重新给 file
赋值了,后面文章也会介绍一下常用的订阅
url: string
@AfterLoad()
generateUrl() {
this.url = envConfig.fileUrl(this.path)
fileUrl(path: string) {
return `http://${this.host}:${this.port}/api/file?path=${path}`;
常用的一些文件相关介绍
__dirname
:当前文件所在目录,字符串
join
:拼接目录, (__dirname, '../public')
resolve
:与join类似,只不过从左往右开始一第一个绝对路径开始解析出绝对路径,否则从右往左...,当你不确定一些目录正反的时候可以用这个,具体可以点进去
import * as dotenv from 'dotenv'
import { resolve } from 'path'
import { readFileSync } from 'fs'
dotenv.config().parsed()
dotenv.config({
path: resolve(process.cwd(), './config/.env'),
}).parsed
readFileSync(
resolve(process.cwd(), './config/apiclient_cert.p12'),
常用的这几个吧,还有其他要用的可以点进仓库查看
分布式注意
如果文件系统和服务器想做成分布式,那么可以将该文件操作直接单独作为一个项目部署到文件服务器即可,通过接口调用文件系统的上传和下载即可,这样文件就和我们开发的业务服务器就分离开了,需要注意的是,我们的文件路径url需要拼接成我们的 file 服务器的地址啦
后面的 minio 本身就比较独立,和我们的 mysql 本身就有自己的服务,因此其本身就支持分布式,不需要额外操作,只需要将其环境部署到文件服务器即可
上传到minio储存库
minio 是一个高性能的文件存储服务仓库,且支持存放超大文件对象,最高支持 5 TB(未来甚至可能更多),且支持购买远端(一般不购买),是很多大企业文件服务器的选择
minio-文档地址、minio-js文档
这里以 mac 为例, 其他的端的文档也有,差不太多,根据自己需要配置即可
brew install minio/stable/minio
创建目录,并运行
//创建~/minio文件夹
mkdir ~/minio
//启动minio服务,并设置保存目录为 ~/minio
minio server ~/minio --console-address :9090
这样就启动成功了,我们可以看到用户名密码,也可以看到地址了,我们直接进入即可
后面我们保存的基本上就在这个 minio 文件夹了,后面是我创建的 bucket 仓库
我们复制上面的地址,然后使用给定的密码进入即可
http:
然后设置 buckets
、 accessKey
配置, object browser
查看管理上传文件(自己简单摸索下,这三个看了可以直接用)
地址和端口号就不多说了吧 127.0.0.1:9000
, :
前后就是,两个 key 记录下来,后面有用(地址如果是另一个服务器部署的,使用另一个服务器的地址,没有端口号就不填写即可)
导入minio
yarn add minio
下面我们唯一需要做的就是看 minio-js文档,这时候可以直接调用里面的api了
我们直接创建 minio 客户端,然后设置我们的信息
import { Client } from 'minio';
this.client = new Client({
endPoint: '127.0.0.1',
port: 9000,
useSSL: false,
accessKey: '123123123'
secretKey: '1231231',
这里面我们主要会用到下面几个方法,一个是直接上传 buff 内容,第二个
putFile(filename: string, buffer: Buffer) {
return this.client.putObject(
envConfig.minioBucketName,
filename,
buffer
fPutFile(filename: string, path: string) {
return this.client.fPutObject(
envConfig.minioBucketName,
filename,
presignedUrl(filename: string) {
return this.client.presignedUrl(
'GET',
envConfig.minioBucketName,
filename,
如果需要用到大文件下载,可以直接去文档搜索 getObject
之类的走下载流程,另外如果出现了数据库和bucket数量不一致,也可以通过遍历方式进行对比,删除多余的信息即可
上传分为两种,一个是只使用minio作为文件系统,另一个是使用 disk 作为文件系统,minio作为备份系统
仅仅上传minio
如果单纯使用 minio,不使用磁盘,请将前面的 disk 相关移除(测试的话,将 module 中相关代码删除),否则我们获取到的 Express.Multer.File
将会不存在 buff
信息,只会有文件路径相关信息
从该上传情况就可以看出来,这上传的数据全部放到内存中,比较适合比较小
的数据,效率稍高
async uploadMinio(
mFile: Express.Multer.File
if (!mFile) {
return ResponseData.fail('请选择文件');
let url = null
let ext = originalname.split('.').at(-1);
let filename = `${new Date().getTime()}.${ext ? ext : ''}`;
try {
await this.minioService.putFile(filename, mFile.buffer);
url = await this.minioService.presignedUrl(filename)
} catch(err) {
console.log(err)
return '失败了';
let file = new File()
file.filename = filename;
file.mimetype = mFile.mimetype;
file.size = mFile.size;
await this.fileRepository.save(file)
return '成功了';
同时上传disk和minio
disk 上传的逻辑和前面的一样,minio 的逻辑发生了一些变化,由于上传disk,Express.Multer.File
获取到的就是文件信息了,buff信息就没了,但是有文件path路径,我们可以通过文件 path 上传到 minio,也就是 fPutFile 方法,如下所示
该方法先通过文件上传到本地
,然后再上传到 minio
,文件上传方式内存占用量较小
,比较适合上传大文件或者混合,我们只需要上传完毕后使用 fs 删除
本地服务器上的文件即可
async uploadMinioEx(
mFile: Express.Multer.File
if (!mFile) {
return ResponseData.fail('请选择文件')
let url = null
const linkPath = join(__dirname, `../../${mFile.path}`)
try {
await this.minioService.fPutFile(mFile.filename, mFile.path)
url = await this.minioService.presignedUrl(mFile.filename)
} catch (err) {
existsSync(linkPath) && unlinkSync(linkPath)
return ResponseData.fail()
unlinkSync(linkPath)
const file = new File()
file.filename = mFile.filename
file.mimetype = mFile.mimetype
file.size = mFile.size
await this.fileRepository.save(file)
return ResponseData.ok({
...file,
直接下载上传文件内容
有时候我们上传的文件可能是一个txt文本,也可能是一个html标签内容,其不是很大,我们可能期望详情页直接返回其内容,而不是再次通过 url 获取,此时我们可以通过,下面方法直接下载内容并返回
getObjectText(filename: string) {
return new Promise<string | null>((resolve, reject) => {
this.client
.getObject(env.config.MINIO_BUCKETNAME, filename)
.then(function (obj) {
if (!obj) {
return resolve(null)
const list: Buffer[] = []
obj?.on('data', function (chunk: Buffer) {
list.push(chunk)
obj?.on('end', function () {
const data = Buffer.concat(list)
resolve(data.toString('utf-8'))
obj?.on('error', function (err) {
reject(err)
.catch(function (err) {
reject(err)
使用服务器下载minio资源(实现特殊条件下的固定url下载)
minio 签名最多七天,因此当我们需要一个比较长久的方式访问时,我们可以直接通过重定向的方式访问,这样就实现了固定url(固定的url,以字符串的方式,通过 域名 + 路由 + 参数 的方式拼接,直接通过拼接的url,实际上就是拼接成一个固定的get请求),还减轻了服务器压力
async download(path: string, res: Response) {
const filePath = join(__dirname, `../../public/${path}`)
try {
await this.minioService.fGetObject(path, filePath)
} catch (err) {
existsSync(filePath) && unlinkSync(filePath)
console.log(err)
return err
res.download(filePath, () => {
unlinkSync(filePath)
res.redirect(await this.minioService.getPresignedUrl(path))
minio 上传下载优化(推荐)
眼尖的人一定会看到问题,上面的minio上传和下载都是通过服务器间接上传下载的,也就是说中间走了一个中间商赚差价,会浪费双倍服务器性能,我们可以直接让用户下载,这样可以减少文件传输的性能消耗
让用户上传有两种方式,一种是用户直接拿着 key 相关,自己直接对接 minio,但这种比较适合内部使用,且引入minio时,会出现这样那样的问题,很不舒服
因此,一般都采用第二种,服务器预签名,然后把需要上传、下载的 url 签名好给客户端,客户端拿着 url 直接对接 minio 上传下载,高效又方便,还能避免客户端引用 minio 的版本等报错问题
我们下面也只会介绍第二种
上传 minio 一共提供了两种方式,一种是二进制,也就是常见的 put上传,一种是 formdata,我们平时用的最多的上传方式就是 formdate了
put上传
(application/octet-stream)
这个一般专门的文件服务器使用这种方式上传,优点是功能比较单一,专注文件,缺点是,无法应对大多数情况,想多传递一个其他参数都不能
this.client.presignedPutObject(
MINIO_BUCKETNAME,
filename,
服务端会直接返回一个url字符串,客户端,则是直接使用 url 进行 put 请求上传,参数都返回在在 query 中,可以直接使用 query + binary 的方式上传
就不多介绍了了,一般的网络请求三方都可以很容易实现上传(例如:umi-request 只需要将 headers 的content-type设置了,直接将文件传给 body 即可)
formdata 上传
(multipart/form-data)
这个也是我们比较常见的上传方式了,基本上很多客户端都在用,包括微信的 wx.upload 都是默认 form-data 上传,支持传递文件的同时传递其他参数,甚至同时支持传递多个文件
const policy = new PostPolicy()
policy.setBucket(MINIO_BUCKETNAME)
policy.setContentType('multipart/form-data')
policy.setKey(filename)
policy.setContentLengthRange(1, 5 * 1024 * 1024 * 1024)
return this.client.presignedPostPolicy(policy)
服务端会返回一个对象,包含 url、form-data,客户端拿到信息,直接使用url、formdata,直接使用 post 的 form-data 方式上传即可不多介绍(直接 new FromData() 然后append即可,将formdata放到body上)
上面无论是直接下载,还是固定url下载,服务端直接下载都是一个问题所在,我们可以分别通过两种方式来解决
对于不需要固定url的,我们可以直接在给用户返回 url 的时候,直接使用 presignedUrl 签名给用户使用(前面也有演示)
this.client.presignedUrl(
'GET',
envConfig.minioBucketName,
filename,
我们一般会创建一个 file 数据库,我们将预签名的信息保存到里面,实际上我们在连查时,给用户返回文件信息的时候,可以以订阅监听的方式,动态给 file 信息追加一个 url,后面的 EventSubscriber
会有介绍,这样就处理了非固定 url 的问题
对于固定url(能不固定就不固定,必要的情况下才使用),我们可以通过拼接get-url 来让用户访问我们的接口获取数据,到这里我们可以不通过自己下载,而是通过重定向的方式,让用户间接访问签名后的 url
res.redirect(await this.minioService.getPresignedUrl(path))
重定向
:返回301、302,一般默认会自动加载重定向后的链接,redirect就是重定向
这样就解决我们常见的问题了
发布注意(带域名)
发布时一般会更新 https 相关,minio 对其是有支持的,带域名的https一般不需要端口号,因此需要需要改动一个设置,不然会报错
this.client = new Client({
endPoint: '127.0.0.1',
useSSL: true,
accessKey: '123123123'
secretKey: '1231231',
当然这个判断也可以使用环境变量 port 来判断是否是 https 的,也可以按照标准的发布流程,新建一个 release 发布分支
设置请求body大小
有时候上传内容过大,虽然post支持很大内容,但服务器会默认限制我们的每次上传的大小,我们可以通过设置 limit 来增加上传内容大小(以避免单个接口刚好超出上限),最好也不要太大,毕竟对于没有校验大小的接口来说,这个设置可能是一个灾难
import { json } from 'express'
app.use(json({ limit: '10mb' }))
上传大字符串问题
如果需要上传大字符串(例如:转化的富文本字符串,包含图片等内容),直接走接口可能不合适,可以跟图片一样走上传接口,为了避免内存和带宽问题,可以将我们的文本等内容转化成文件对象,然后直接传递即可(本地大文件不需要,会自动利用 path 路径分片上传即可)
const text = "......"
const file = new File([text], '文本')
let blob = new Blob([str], {type: "text/plain;charset=utf-8"});
const file = new File([blob], name, {type: "text/plain"});
formdata上传 upload 时,则可以直接使用该 file 即可
一般通用上传文件为了方便,基本都是formdata上传,这样既可以传递文件,还可以传递文件以外的内容
专门的二进制方式传递 application/octet-stream
,这种一般是专门传递文件的文件服务使用,可以根据需要的自行检索即可
相信也了解过分布式,我们的多个服务可能分不到多个服务器上,因此会涉及到不同的ip端口号等,这也是需要额外注意的
ps
: 如果是小项目,只有一个用户小头像,那么不配置文件都是可以的,让用户直接 post 上传 base64 图片即可,我们直接保存到 mysql 也不是不行
这篇就讲这么多了,希望大家有所收获