TypeScript 前端上传文件到 MinIO
在以前,前端要上传文件到服务端,比较的麻烦,要么通过 HTTP 服务上传,要么通过 FTP 上传。这两者的可靠性都极低。
但是,后来,有了对象存储服务(Object Storage Service),对象存储也称为基于对象的存储,是一种计算机数据存储架构,旨在处理大量非结构化数据。与其他架构不同,它将数据指定为不同的单元,并捆绑元数据和唯一标识符,用于查找和访问每个数据单元。
OSS 具有与平台无关的 RESTful API 接口,您可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。
网上比较著名的开源 OSS 有:MinIO和Ceph。其中 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_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 下,我们可用的文件上传方法有三种,可用于实现文件的上传:
XMLHttpRequest
Fetch API
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 可供调用:
putObject 从流上传
fPutObject 从文件上传
PresignedPutObject 提供一个临时的 HTTP PUT 操作预签名上传链接以供上传
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(); formData.append('file', file); Object.entries(data).forEach(([k, v]) => { formData.append(k, v); });
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(); formData.append('file', file); Object.entries(data).forEach(([k, v]) => { formData.append(k, v); });
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(); formData.append('file', file); Object.entries(data).forEach(([k, v]) => { formData.append(k, v); });
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,然后把文件打进去,如果用putObject和fPutObject这两个方法上传,这是没问题的:
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)}
复制代码
如果使用以上的方式上传,文件头会被插入一段数据,看起来像是这样子的:
------WebKitFormBoundaryaym16ehT29q60rUxContent-Disposition: form-data; name="file"; filename="webfonts.zip"Content-Type: application/zip
复制代码
它是遵照了 rfc1867 定义的协议,插入的协议数据。
但是如果是使用presignedPutObject的方式则是不行的,接收到的文件里面将会有上面的协议数据,不需要构造FormData,直接发送File就可以了。
3. 使用Axios上传的时候,需要自己把Content-Type填写成为file.type
直接使用XMLHttpRequest和Fetch API都会自动填写成为文件真实的Content-Type。而Axios则不会,需要自己填写进去,或许是我不会使用Axios,但是这是一个需要注意的地方,否则在 MinIO 里边的Content-Type会被填写成为Axios默认的Content-Type。
示例代码
Github: https://github.com/tx7do/minio-typescript-example
Gitee: https://gitee.com/tx7do/minio-typescript-example
评论