添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
相关文章推荐
另类的火柴  ·  Java SE 11 Binaries ...·  11 月前    · 
彷徨的热水瓶  ·  Visual Studio 中的 ...·  1 年前    · 

开发过程中,肯定会用到上传文件功能,这里目前就讨论两种情况,分别是 直接使用文件系统保存到磁盘(常见) 文件存储服务库(minio)

还有其他存储服务的都可以类推,其中 minio 服务和 mysql 一样,都是需要下载配置的,算是用的很多的一个服务了(并且还支持远端购买空间,一般都是用自己服务器吧😅)

minio 地址

上传到磁盘

这里的磁盘默认就是在本地,如果文件服务器设置到另一个大储存空间那端,那么另一端也可以像这样配置,只不过就是需要其他服务器间接调用,甚至可以直接调用都可以

使用前,我们先安装文件库

yarn add multer @nestjs/platform-express
yarn add @types/multer --dev //获取ts支持
//这是单纯为为了文件不重名且方便管理引用的事件库,比较小,足够日常使用,当然也可以使用moment
yarn add dayjs

创建 file 模块,用于我们编写功能,我们仍然是使用 res 创建几个常用文件

nest g res file

ps: File 库会跟系统的一些库重名,引用时额外注意位置,有必要的话改个名字(例如: store、diskfile、filestore都行)

如下所示已创建完毕了

我们进入 .file.module 中添加内容, @Moduleimport 我们的数据库 File,同时如下所示,注册 MulterModule 模块,注册后,每次上传文件都会走到下面的 filename 后买你的回调

这是我们下载用的操作,基本都在这里了,设置目录、文件名字等等

import dayjs = require('dayjs');
//上传配置
imports: [
    TypeOrmModule.forFeature([File]), //后续再说其作用
    MulterModule.register({
      storage: diskStorage({
        // 配置文件上传后的文件夹路径,设置到我们实际项目并列的 public 文件夹中
        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) {//Response要用 express中的
    //他们两个在client实际使用差不多,都可以用来展示
    //不同时浏览器打开时download会直接下载,并将文件名带出,sendFile则直接显示图片或者没有名字视频视频
    // res.sendFile(join(__dirname, `../../${path}`))
    res.download(join(__dirname, `../../${path}`))

而给用户返回的url我们要拼接好了,不然客户端不能直接用,那就惨了

export function getFileUrl(file: File) {
    return `http://${env.config.HOST}:${env.config.PORT}/api/file?path=${file.path}`

fileoriginalname 乱码问题

上传过程中,可能会出现需要用到原文件名称的问题,Express.Multer.File默认返回的 originalname 就是一个乱码,因此要是保存到数据库和返回都是乱码,怎么办呢,通过 buffer 转化后在保存

...file: Express.Multer.File
//一定要按照这个传递哈,否则还是会转化不成功
Buffer.from(file.originalname, "latin1").toString("utf8")

上面只介绍了怎么用,但是却没有配置文档,下面配置一下参数文档(返回的前面有介绍,就不介绍了)

@ApiOperation({
    summary: '上传文件到磁盘'
@ApiConsumes('multipart/form-data') //设置类型form-data
ApiBody({ //设置file类型
    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); //如果想与 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); //如果想与 user 建立联系,可以继续往后写

便利性操作 afterLoad

通过 afterLoad 装饰器可以让我们查询 entity 后,自动调用 afterLoad 后的方法 给 url 赋值,这样返回时就不用每次都重新给 file 赋值了,后面文章也会介绍一下常用的订阅

url: string
//entity生成后会进行回调该方法
@AfterLoad()
generateUrl() {
    this.url = envConfig.fileUrl(this.path)
//envConfig
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' //join和resolve都在这里
import { readFileSync } from 'fs' //读取文件信息
//可以直接目录中取环境变量文件(目录中.env文件)
dotenv.config().parsed()
//获取指定文件目录下的环境变量,cwd表示我们的项目目录
dotenv.config({
    path: resolve(process.cwd(), './config/.env'),
}).parsed
//读取文件信息返回buff
readFileSync(
    resolve(process.cwd(), './config/apiclient_cert.p12'),

常用的这几个吧,还有其他要用的可以点进仓库查看

分布式注意

如果文件系统和服务器想做成分布式,那么可以将该文件操作直接单独作为一个项目部署到文件服务器即可,通过接口调用文件系统的上传和下载即可,这样文件就和我们开发的业务服务器就分离开了,需要注意的是,我们的文件路径url需要拼接成我们的 file 服务器的地址啦

后面的 minio 本身就比较独立,和我们的 mysql 本身就有自己的服务,因此其本身就支持分布式,不需要额外操作,只需要将其环境部署到文件服务器即可

上传到minio储存库

minio 是一个高性能的文件存储服务仓库,且支持存放超大文件对象,最高支持 5 TB(未来甚至可能更多),且支持购买远端(一般不购买),是很多大企业文件服务器的选择

minio-文档地址minio-js文档

这里以 mac 为例, 其他的端的文档也有,差不太多,根据自己需要配置即可

//安装minio
brew install minio/stable/minio

创建目录,并运行

//创建~/minio文件夹
mkdir ~/minio
//启动minio服务,并设置保存目录为 ~/minio
minio server ~/minio --console-address :9090

这样就启动成功了,我们可以看到用户名密码,也可以看到地址了,我们直接进入即可

后面我们保存的基本上就在这个 minio 文件夹了,后面是我创建的 bucket 仓库

我们复制上面的地址,然后使用给定的密码进入即可

http://127.0.0.1:9090

然后设置 bucketsaccessKey 配置, 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', //ip,我们的 minio 所在服务地址
    port: 9000, //端口号,又就传没有就不传
    useSSL: false,
    accessKey: '123123123' //我们设置的 accesskey复制一下即可,
    secretKey: '1231231',
    // partSize: 1024 * 1024 * 1024 * 5, //上传片的大小,默认64M,最多5g,建议大文件分片上传,不要开太大,避免网络不稳出现的失败

这里面我们主要会用到下面几个方法,一个是直接上传 buff 内容,第二个

//直接上传接收到的 buff
putFile(filename: string, buffer: Buffer) {
    //如果想两个端都存在文件,可以使用 fPutObject 逻辑更简单
    return this.client.putObject(
        envConfig.minioBucketName,
        filename,
        buffer
    //{ etag: '4889457ca823d079a800e4a5f427b353', versionId: null }
//如果本地磁盘保存了(结合上面的磁盘),备份到minio,可以直接利用file来上海窜
fPutFile(filename: string, path: string) {
    return this.client.fPutObject(
        envConfig.minioBucketName,
        filename,
//获取url签名,默认7天,可以设置时间,数据库获取图片url时,可以通过这个获取
//避免别人知道url就可以肆意访问我们的仓库数据(另外没授权的用户也不能随意访问)
presignedUrl(filename: string) {
    return this.client.presignedUrl(
        'GET',
        envConfig.minioBucketName,
        filename,
        //  7 * 24 * 60 ^ 60 //时长 s 默认7天

如果需要用到大文件下载,可以直接去文档搜索 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
    //生成一个唯一的 filename
    let ext = originalname.split('.').at(-1);
    // 在此处自定义保存后的文件名称
    let filename = `${new Date().getTime()}.${ext ? ext : ''}`;
    // let filename = getFilename(mFile.originalname); //这是我们封装的方便使用
    try {
      //直接 put 到 minio 中即可
      await this.minioService.putFile(filename, mFile.buffer);
      //我们还要顺道给用户返回一个url,不然他上传后无法访问,还得掉接口
      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))
//url拼接,例如:我们通过域名 + 我们的接口路由(/api/file/download) + 参数(path=...)
//`域名/api/file/download?path=${filename}`

minio 上传下载优化(推荐)

眼尖的人一定会看到问题,上面的minio上传和下载都是通过服务器间接上传下载的,也就是说中间走了一个中间商赚差价,会浪费双倍服务器性能,我们可以直接让用户下载,这样可以减少文件传输的性能消耗

让用户上传有两种方式,一种是用户直接拿着 key 相关,自己直接对接 minio,但这种比较适合内部使用,且引入minio时,会出现这样那样的问题,很不舒服

因此,一般都采用第二种,服务器预签名,然后把需要上传、下载的 url 签名好给客户端,客户端拿着 url 直接对接 minio 上传下载,高效又方便,还能避免客户端引用 minio 的版本等报错问题

我们下面也只会介绍第二种

上传 minio 一共提供了两种方式,一种是二进制,也就是常见的 put上传,一种是 formdata,我们平时用的最多的上传方式就是 formdate了

put上传(application/octet-stream)

这个一般专门的文件服务器使用这种方式上传,优点是功能比较单一,专注文件,缺点是,无法应对大多数情况,想多传递一个其他参数都不能

//直接通过 presignedPutObject 预签名,使用 bucket 和 filename 即可
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 上传,支持传递文件的同时传递其他参数,甚至同时支持传递多个文件

//服务端--支持限制大小
//直接通过 presignedPutObject 预签名,使用 bucket 和 filename 即可
const policy = new PostPolicy()
policy.setBucket(MINIO_BUCKETNAME)
policy.setContentType('multipart/form-data')
policy.setKey(filename)
policy.setContentLengthRange(1, 5 * 1024 * 1024 * 1024) //5G z
return this.client.presignedPostPolicy(policy)

服务端会返回一个对象,包含 url、form-data,客户端拿到信息,直接使用url、formdata,直接使用 post 的 form-data 方式上传即可不多介绍(直接 new FromData() 然后append即可,将formdata放到body上)

上面无论是直接下载,还是固定url下载,服务端直接下载都是一个问题所在,我们可以分别通过两种方式来解决

对于不需要固定url的,我们可以直接在给用户返回 url 的时候,直接使用 presignedUrl 签名给用户使用(前面也有演示)

//下面方法会直接其那面一个期限的url,我们可以动态返回给用户
this.client.presignedUrl(
    'GET',
    envConfig.minioBucketName,
    filename,
    //  7 * 24 * 60 ^ 60 //时长 s 默认7天

我们一般会创建一个 file 数据库,我们将预签名的信息保存到里面,实际上我们在连查时,给用户返回文件信息的时候,可以以订阅监听的方式,动态给 file 信息追加一个 url,后面的 EventSubscriber 会有介绍,这样就处理了非固定 url 的问题

对于固定url(能不固定就不固定,必要的情况下才使用),我们可以通过拼接get-url 来让用户访问我们的接口获取数据,到这里我们可以不通过自己下载,而是通过重定向的方式,让用户间接访问签名后的 url

//直接重定向到我们签名的url即可
res.redirect(await this.minioService.getPresignedUrl(path))

重定向:返回301、302,一般默认会自动加载重定向后的链接,redirect就是重定向

这样就解决我们常见的问题了

发布注意(带域名)

发布时一般会更新 https 相关,minio 对其是有支持的,带域名的https一般不需要端口号,因此需要需要改动一个设置,不然会报错

this.client = new Client({
    endPoint: '127.0.0.1', //ip,我们的 minio 所在服务地址
    useSSL: true, //https需要开启这个,另外域名是没有端口号,端口号删掉即可
    accessKey: '123123123' //我们设置的 accesskey复制一下即可,
    secretKey: '1231231',
    // partSize: 1024 * 1024 * 1024 * 5, //上传片的大小,默认64M,最多5g,建议大文件分片上传,不要开太大,避免网络不稳出现的失败

当然这个判断也可以使用环境变量 port 来判断是否是 https 的,也可以按照标准的发布流程,新建一个 release 发布分支

设置请求body大小

有时候上传内容过大,虽然post支持很大内容,但服务器会默认限制我们的每次上传的大小,我们可以通过设置 limit 来增加上传内容大小(以避免单个接口刚好超出上限),最好也不要太大,毕竟对于没有校验大小的接口来说,这个设置可能是一个灾难

import { json } from 'express'
app.use(json({ limit: '10mb' })) //默认就是100kb

上传大字符串问题

如果需要上传大字符串(例如:转化的富文本字符串,包含图片等内容),直接走接口可能不合适,可以跟图片一样走上传接口,为了避免内存和带宽问题,可以将我们的文本等内容转化成文件对象,然后直接传递即可(本地大文件不需要,会自动利用 path 路径分片上传即可)

//前端中可以进行下面转化
const text = "......"
const file = new File([text], '文本')
//或者转化为blob或者buffer都可以,下面可以转化为blob
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 也不是不行

这篇就讲这么多了,希望大家有所收获