前端使用TypeScript实现上传文件到MinIO

前端使用TypeScript实现上传文件到MinIO

在以前,前端要上传文件到服务端,比较的麻烦,要么通过HTTP服务上传,要么通过FTP上传。这两者的可靠性都极低。

但是,后来,有了对象存储服务(Object Storage Service),对象存储也称为基于对象的存储,是一种计算机数据存储架构,旨在处理大量非结构化数据。与其他架构不同,它将数据指定为不同的单元,并捆绑元数据和唯一标识符,用于查找和访问每个数据单元。

OSS具有与平台无关的RESTful API接口,您可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。

网上比较著名的开源OSS有:MinIOCeph。其中MinIO的使用率是越来越高,可以说是很普及了。因此,我首选使用它来做文件上传和管理的系统。

什么是MinIO?

官方解释:MinIO 是一个用 Golang 开发的基于 Apache License v2.0 开源协议的对象存储服务。

它兼容亚马逊S3云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等,而一个对象文件可以是任意大小,从几kb到最大5T不等。

MinIO是一个非常轻量的服务,可以很简单的和其他应用的结合,类似 NodeJS, Redis 或者 MySQL。

Minio使用纠删码erasure code和校验和checksum来保护数据免受硬件故障和数据损坏。
因此,即便您丢失一半数量(N/2)的硬盘,您仍然可以恢复数据。

本地Docker部署测试服务器

docker pull bitnami/minio:latest

# MINIO_ROOT_USER最少3个字符
# MINIO_ROOT_PASSWORD最少8个字符
# 第一次运行的时候,服务会自动关闭,手动再次启动就可以正常运行了.
docker run -itd \
    --name minio-server \
    -p 9000:9000 \
    -p 9001:9001 \
    --env MINIO_SERVER_URL="http://127.0.0.1:9000" \
    --env MINIO_BROWSER_REDIRECT_URL="http://127.0.0.1:9001" \    
    --env MINIO_ROOT_USER="root" \
    --env MINIO_ROOT_PASSWORD="123456789" \
    --env MINIO_DEFAULT_BUCKETS='images' \
    --env MINIO_FORCE_NEW_KEYS="yes" \
    --env BITNAMI_DEBUG=true \
    bitnami/minio:latest

TypeScript实现文件上传

在TypeScript下,我们可用的文件上传方法有三种,可用于实现文件的上传:

  1. XMLHttpRequest
  2. Fetch API
  3. Axios

需要注意的是: 事实上,后两种API都是对XMLHttpRequest进行的封装。

1. XMLHttpRequest

function xhrUploadFile(file: File, url: string) {
  const xhr = new XMLHttpRequest();
  xhr.open('PUT', url, true);
  xhr.send(file);

  xhr.onload = () => {
    if (xhr.status === 200) {
      console.log(`${file.name} 上传成功`);
    } else {
      console.error(`${file.name} 上传失败`);
    }
  };
}

2. Fetch API

function fetchUploadFile(file: File, url: string) {
  fetch(url, {
    method: 'PUT',
    body: file,
  })
    .then((response) => {
      console.log(`${file.name} 上传成功`, response);
    })
    .catch((error) => {
      console.error(`${file.name} 上传失败`, error);
    });
}

3. Axios

function axiosUploadFile(file: File, url: string) {
  const instance = axios.create();
  instance
    .put(url, file, {
      headers: {
        'Content-Type': file.type,
      },
    })
    .then(function (response) {
      console.log(`${file.name} 上传成功`, response);
    })
    .catch(function (error) {
      console.error(`${file.name} 上传失败`, error);
    });
}

MinIO上传API

它有4个API可供调用:

  1. putObject 从流上传
  2. fPutObject 从文件上传
  3. PresignedPutObject 提供一个临时的HTTP PUT 操作预签名上传链接以供上传
  4. PresignedPostPolicy 提供一个临时的HTTP POST 操作预签名上传链接以供上传

使用方法1和2的话,必须要在前端暴露用于连接MinIO的访问密钥。这样很不安全,并且官方的Js客户端也压根就没想过开放给浏览器。

而使用方法3和4的话,我们可以由服务端来生成一个临时的上传链接,提供给前端上传之用,无需暴露访问MinIO的密钥给前端,这样非常的安全,因此我采用的是第3、4种方式

在下面,我们主要讨论的也是这两种方法,前两种不实用,故而不做任何讨论。

第三种方式,官方有一篇文章: Upload Files Using Pre-signed URLs

实现go后端

首先对MinIO的SDK做一个简单的封装:

package minio

import (
    "context"
    "log"
    "net/url"
    "time"

    "github.com/minio/minio-go/v7"
    "github.com/minio/minio-go/v7/pkg/credentials"
)

const (
    defaultExpiryTime = time.Second * 24 * 60 * 60 // 1 day

    endpoint        string = "localhost:9000"
    accessKeyID     string = "root"
    secretAccessKey string = "123456789"
    useSSL          bool   = false
)

type Client struct {
    cli *minio.Client
}

func NewMinioClient() *Client {
    cli, err := minio.New(endpoint, &minio.Options{
        Creds:  credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
        Secure: useSSL,
    })
    if err != nil {
        log.Fatalln(err)
    }

    return &Client{
        cli: cli,
    }
}

func (c *Client) PostPresignedUrl(ctx context.Context, bucketName, objectName string) (string, map[string]string, error) {
    expiry := defaultExpiryTime

    policy := minio.NewPostPolicy()
    _ = policy.SetBucket(bucketName)
    _ = policy.SetKey(objectName)
    _ = policy.SetExpires(time.Now().UTC().Add(expiry))

    presignedURL, formData, err := c.cli.PresignedPostPolicy(ctx, policy)
    if err != nil {
        log.Fatalln(err)
        return "", map[string]string{}, err
    }

    return presignedURL.String(), formData, nil
}

func (c *Client) PutPresignedUrl(ctx context.Context, bucketName, objectName string) (string, error) {
    expiry := defaultExpiryTime

    presignedURL, err := c.cli.PresignedPutObject(ctx, bucketName, objectName, expiry)
    if err != nil {
        log.Fatalln(err)
        return "", err
    }

    return presignedURL.String(), nil
}

然后我们需要提供两个接口用于提供给前端获取MinIO的预签名链接:

package http

import (
    "context"
    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "main/minio"
    "net/http"
)

type Response struct {
    Code int         `json:"code"`
    Msg  string      `json:"msg"`
    Data interface{} `json:"data"`
}

func ResponseJSON(c *gin.Context, httpCode, errCode int, msg string, data interface{}) {
    c.JSON(httpCode, Response{
        Code: errCode,
        Msg:  msg,
        Data: data,
    })
    return
}

type Server struct {
    srv         *gin.Engine
    minioClient *minio.Client
}

func NewHttpServer() *Server {
    srv := &Server{
        srv:         gin.New(),
        minioClient: minio.NewMinioClient(),
    }

    srv.init()

    return srv
}

func (s *Server) init() {
    s.srv.Use(
        gin.Logger(),
        gin.Recovery(),
        cors.Default(),
    )
    s.registerRouter()
}

func (s *Server) registerRouter() {
    s.srv.GET("/presignedPutUrl/:filename", s.handlePutPresignedUrl)
    s.srv.GET("/presignedPostUrl/:filename", s.handlePostPresignedUrl)
}

func (s *Server) handlePutPresignedUrl(c *gin.Context) {
    fileName := c.Param("filename")

    presignedURL, err := s.minioClient.PutPresignedUrl(context.Background(), "images", fileName)
    if err != nil {
        c.String(500, "get presigned url failed")
        return
    }

    type ResponseData struct {
        Url string `json:"url"`
    }
    var resp ResponseData
    resp.Url = presignedURL
    ResponseJSON(c, http.StatusOK, 200, "", resp)
}

func (s *Server) handlePostPresignedUrl(c *gin.Context) {
    fileName := c.Param("filename")

    presignedURL, formData, err := s.minioClient.PostPresignedUrl(context.Background(), "images", fileName)
    if err != nil {
        c.String(500, "get presigned url failed")
        return
    }

    type ResponseData struct {
        Url      string            `json:"url"`
        FormData map[string]string `json:"formData"`
    }
    var resp ResponseData
    resp.Url = presignedURL
    resp.FormData = formData
    ResponseJSON(c, http.StatusOK, 200, "", resp)
}

func (s *Server) Run() {
    // Listen and serve on 0.0.0.0:8080
    _ = s.srv.Run(":8080")
}

这样我们就有了一个提供MinIO预签名的REST服务了。

前端实现PUT方法上传文件

import axios from 'axios';

export class PutFile {
  static xhr(file: File, url: string) {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', url, true);
    xhr.send(file);

    xhr.onload = () => {
      if (xhr.status === 200 || xhr.status === 204) {
        console.log(`[${xhr.status}] ${file.name} 上传成功`);
      } else {
        console.error(`[${xhr.status}] ${file.name} 上传失败`);
      }
    };
  }

  static fetch(file: File, url: string) {
    fetch(url, {
      method: 'PUT',
      body: file,
    })
      .then((response) => {
        console.log(`${file.name} 上传成功`, response);
      })
      .catch((error) => {
        console.error(`${file.name} 上传失败`, error);
      });
  }

  static axios(file: File, url: string) {
    axios
      .put(url, file, {
        headers: {
          'Content-Type': file.type,
        },
      })
      .then(function (response) {
        console.log(`${file.name} 上传成功`, response);
      })
      .catch(function (error) {
        console.error(`${file.name} 上传失败`, error);
      });
  }
}

export function retrievePutUrl(file: File, cb: (file: File, url: string) => void) {
  const url = `http://localhost:8080/presignedPutUrl/${file.name}`;
  axios.get(url)
    .then(function (response) {
      cb(file, response.data.data.url);
    })
    .catch(function (error) {
      console.error(error);
    });
}

export function xhrPutFile(file?: File) {
  console.log('XhrPutFile', file);
  if (file) {
    retrievePutUrl(file, (file, url) => {
      PutFile.xhr(file, url);
    });
  }
}

export function fetchPutFile(file?: File) {
  console.log('FetchPutFile', file);
  if (file) {
    retrievePutUrl(file, (file, url) => {
      PutFile.fetch(file, url);
    });
  }
}

export function axiosPutFile(file?: File) {
  console.log('AxiosPutFile', file);
  if (file) {
    retrievePutUrl(file, (file, url) => {
      PutFile.axios(file, url);
    });
  }
}

前端实现POST方法上传文件

import axios from 'axios';

export class PostFile {
  static xhr(file: File, url: string, data: object) {
    const formData = new FormData();
    Object.entries(data).forEach(([k, v]) => {
      formData.append(k, v);
    });
    formData.append('file', file);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', url, true);
    xhr.send(formData);

    xhr.onload = () => {
      if (xhr.status === 200 || xhr.status === 204) {
        console.log(`[${xhr.status}] ${file.name} 上传成功`);
      } else {
        console.error(`[${xhr.status}] ${file.name} 上传失败`);
      }
    };
  }

  static fetch(file: File, url: string, data: object) {
    const formData = new FormData();
    Object.entries(data).forEach(([k, v]) => {
      formData.append(k, v);
    });
    formData.append('file', file);

    fetch(url, {
      method: 'POST',
      body: formData,
    })
      .then((response) => {
        console.log(`${file.name} 上传成功`, response);
      })
      .catch((error) => {
        console.error(`${file.name} 上传失败`, error);
      });
  }

  static axios(file: File, url: string, data: object) {
    const formData = new FormData();
    Object.entries(data).forEach(([k, v]) => {
      formData.append(k, v);
    });
    formData.append('file', file);

    axios.post(
      url,
      formData,
      {
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      })
      .then(function (response) {
        console.log(`${file.name} 上传成功`, response);
      })
      .catch(function (error) {
        console.error(`${file.name} 上传失败`, error);
      });
  }
}

export function retrievePostUrl(file: File, cb: (file: File, url: string, data: object) => void) {
  const url = `http://localhost:8080/presignedPostUrl/${file.name}`;
  axios.get(url)
    .then(function (response) {
      cb(file, response.data.data.url, response.data.data.formData);
    })
    .catch(function (error) {
      console.error(error);
    });
}

export function xhrPostFile(file?: File) {
  console.log('xhrPostFile', file);
  if (file) {
    retrievePostUrl(file, (file: File, url: string, data: object) => {
      PostFile.xhr(file, url, data);
    });
  }
}

export function fetchPostFile(file?: File) {
  console.log('fetchPostFile', file);
  if (file) {
    retrievePostUrl(file, (file: File, url: string, data: object) => {
      PostFile.fetch(file, url, data);
    });
  }
}

export function axiosPostFile(file?: File) {
  console.log('axiosPostFile', file);
  if (file) {
    retrievePostUrl(file, (file: File, url: string, data: object) => {
      PostFile.axios(file, url, data);
    });
  }
}

踩过的坑

1. presignedPutObject方式上传提交的方法必须得是PUT

我试过了用POST去上传文件,但是结果显然是:我失败了,必须得用PUT去上传,正如其方法名中带有Put

2. 直接发送File即可

看了不少文章都是这么干的: 构造一个FormData,然后把文件打进去,如果用putObjectfPutObject这两个方法上传,这是没问题的:

fileUpload(file) {
    const url = 'http://example.com/file-upload';
    const formData = new FormData();
    formData.append('file', file)
    const config = {
        headers: {
            'content-type': 'multipart/form-data'
        }
    }
    return post(url, formData, config)
}

如果使用以上的方式上传,文件头会被插入一段数据,看起来像是这样子的:

------WebKitFormBoundaryaym16ehT29q60rUx
Content-Disposition: form-data; name="file"; filename="webfonts.zip"
Content-Type: application/zip

它是遵照了 rfc1867 定义的协议,插入的协议数据。

但是如果是使用presignedPutObject的方式则是不行的,接收到的文件里面将会有上面的协议数据,不需要构造FormData,直接发送File就可以了。

3. 使用Axios上传的时候,需要自己把Content-Type填写成为file.type

直接使用XMLHttpRequestFetch API都会自动填写成为文件真实的Content-Type。而Axios则不会,需要自己填写进去,或许是我不会使用Axios,但是这是一个需要注意的地方,否则在MinIO里边的Content-Type会被填写成为Axios默认的Content-Type

4. 使用POST方法提交FormData的时候,file表单域必须在最后一位

这个是在:https://help.aliyun.com/zh/oss/how-to-handle-common-errors-when-the-postobject-operation-is-called#title-ye0-74w-7h8 里面发现的解决方法。

我一开始把file表单域放在的第一位,然后,报错了:

The body of your POST request is not well-formed multipart/form-data

或者

The name of the uploaded key is missing

完全摸不着头脑,其实,就是因为这个file表单域的次序问题。

5. 403错误码的问题

用Put方法上传文件,碰到了403的报错,死活传不上去文件,本质上,是因为验证不通过。验证不通过的原因有很多,比如:时间不对,链接还没开始就过期了、主机不匹配……

我碰到的403问题是主机不匹配导致的——当然,这是事后才知道的——MinIO的服务器连接地址用外网地址也好,127.0.0.1也好,都报错。只有localhost才能够成功上传。一开始,我真是百思不得其解。

我们来看Put提交的表单项里面有一条:

X-Amz-SignedHeaders: host

根据亚马逊的文档:Authenticating Requests: Using Query Parameters (AWS Signature Version 4) 里面的描述,这一条的意思是,签名里面加了服务器的主机名,作为验证的条件之一。

当你打开管理后台,通过:Administrator -> Monitoring -> Metrics的访问路径到达汇总页面,你会发现在Servers下面,本机地址是localhost:9001

那么,原因就在这里了,主机名不一致,自然是通过不了签名验证的。那么,我们可以怎么去解决这个问题呢?

我经过了尝试,发现预签名产生的预签名链接地址居然是MinIO客户端连接MinIO所使用的endpoint,我之前以为我反正go服务和MinIO服务在一台机器上,那么我自然是通过localhost来连接会更好一些,不想会有这样的副作用——一切,都是我想当然的结果——总之,连接MinIO的时候,填写外网IP就搞定了。

现在,这个问题是解决了,但是我又出来了另外一个问题:如果我想用域名访问呢?那该怎么办?能不能够有像Nignx那样绑定虚拟主机的方法来绑定域名?后来查资料,还真可以:

我们可以修改环境变量MINIO_SERVER_URLMINIO_BROWSER_REDIRECT_URL来达成,它们可以用来进行域名的绑定:

  • MINIO_SERVER_URL,它指向的是API的端口,默认为9000端口;
  • MINIO_BROWSER_REDIRECT_URL,它指向的是控制台的端口,默认为9001端口。

我们如果使用Docker进行部署,可以在创建的时候注入环境变量,如果不使用Docker部署则可以用export的方式注入。

它可以是域名(比如:http://minio.xxxx.com),也可以直接ip+端口(比如:http://1.1.1.1:9000)。 但,需要注意的是,一定要加http://或者https://主机头,不然无法访问。

示例代码

Github: https://github.com/tx7do/minio-typescript-example
Gitee: https://gitee.com/tx7do/minio-typescript-example

  • 后端采用go+gin实现;
  • 前端有React和Vue的实现,要实现进度条和多文件上传也是容易的。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,701评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,649评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,037评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,994评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,018评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,796评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,481评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,370评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,868评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,014评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,153评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,832评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,494评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,039评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,156评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,437评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,131评论 2 356

推荐阅读更多精彩内容