牵着老婆满街逛

随便写点技术性的文章


JavaScript/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来达成,它们可以用来进行域名的绑定:

我们如果使用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