JavaScript/TypeScript 前端實現文件上傳到 MinIO 完整指南
以往前端實現文件上傳到服務端,常用方案為 HTTP 上傳或 FTP 上傳,但這兩種方式均存在明顯短板:HTTP 上傳易受網絡波動影響,可靠性較差;FTP 配置復雜且安全性不足。隨著對象存儲服務(Object Storage Service, OSS)的普及,這一問題得到了有效解決。
對象存儲(基于對象的存儲)是一種專為海量非結構化數據設計的存儲架構。與傳統(tǒng)存儲不同,它將數據封裝為獨立對象,捆綁元數據和唯一標識符,便于快速查找與訪問。OSS 提供與平臺無關的 RESTful API 接口,支持在任意應用、任意時間、任意地點存儲和訪問各類數據。
目前主流的開源 OSS 方案包括 MinIO和Ceph。其中 MinIO 憑借輕量、易用、兼容 S3 接口等優(yōu)勢,使用率持續(xù)攀升,成為開源對象存儲的首選方案之一。本文將詳細介紹如何基于 JavaScript/TypeScript 前端實現文件上傳到 MinIO。
一、什么是 MinIO?
官方定義:MinIO 是基于 Apache License v2.0 開源協(xié)議,采用 Golang 開發(fā)的對象存儲服務。
它完全兼容亞馬遜 S3 云存儲服務接口,特別適合存儲圖片、視頻、日志文件、備份數據、容器/虛擬機鏡像等大容量非結構化數據,支持單個對象文件從幾 KB 到 5T 的大小范圍。
MinIO 具備輕量特性,可輕松與 NodeJS、Redis、MySQL 等應用集成。同時,它通過糾刪碼(erasure code)和校驗和(checksum)保障數據安全——即使丟失一半數量(N/2)的硬盤,仍可完整恢復數據。
二、本地 Docker 部署 MinIO 測試服務
通過 Docker 可快速部署 MinIO 測試環(huán)境,步驟如下:
# 拉取最新版 MinIO 鏡像
docker pull bitnami/minio:latest
# 啟動 MinIO 容器
# 注意:MINIO_ROOT_USER 至少 3 個字符,MINIO_ROOT_PASSWORD 至少 8 個字符
# 首次運行后服務可能自動關閉,手動重啟容器即可正常使用
docker run -itd \
--name minio-server \
-p 9000:9000 \ # API 端口
-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' \ # 自動創(chuàng)建名為 images 的存儲桶
--env MINIO_FORCE_NEW_KEYS="yes" \
--env BITNAMI_DEBUG=true \
bitnami/minio:latest
三、TypeScript 實現文件上傳的核心方案
前端基于 TypeScript 上傳文件到 MinIO,核心有三種 HTTP 請求方案可選,分別適配不同開發(fā)場景:
- XMLHttpRequest:傳統(tǒng)方案,兼容性好,支持進度監(jiān)聽等細粒度控制
- Fetch API:現代瀏覽器原生支持,基于 Promise,語法更簡潔
- Axios:第三方 HTTP 庫,支持攔截器、取消請求等增強功能,生態(tài)完善
3.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} 上傳失敗`);
}
};
}
3.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.3 Axios 實現
function axiosUploadFile(file: File, url: string) {
const instance = axios.create();
instance
.put(url, file, {
headers: {
'Content-Type': file.type, // 需指定文件真實 Content-Type
},
})
.then(function (response) {
console.log(`${file.name} 上傳成功`, response);
})
.catch(function (error) {
console.error(`${file.name} 上傳失敗`, error);
});
}
四、MinIO 上傳 API 選型:安全優(yōu)先
MinIO 提供 4 種核心上傳 API,需根據安全性和使用場景選擇:
- putObject:從流上傳
- fPutObject:從文件上傳
- PresignedPutObject:生成臨時 PUT 預簽名 URL,用于前端上傳
- PresignedPostPolicy:生成臨時 POST 預簽名 URL,用于前端上傳
關鍵選型說明:
使用 putObject 和 fPutObject 時,需在前端暴露 MinIO 的訪問密鑰(Access Key/Secret Key),存在嚴重安全隱患;且 MinIO 官方 JavaScript 客戶端未針對瀏覽器環(huán)境適配,因此不推薦前端直接使用這兩種方案。
PresignedPutObject 和 PresignedPostPolicy 方案通過服務端生成臨時預簽名 URL,前端僅需使用該臨時 URL 上傳文件,無需暴露核心密鑰,安全性極高,因此本文重點講解這兩種方案。
MinIO 官方關于預簽名 URL 上傳的詳細說明:Upload Files Using Pre-signed URLs
五、前后端完整實現
整體架構:前端通過調用后端接口獲取 MinIO 預簽名 URL,再通過該 URL 直接將文件上傳到 MinIO。其中后端采用 Go + Gin 框架實現,負責 MinIO 客戶端封裝和預簽名 URL 生成。
5.1 Go 后端實現
首先封裝 MinIO 客戶端,統(tǒng)一管理連接和預簽名 URL 生成邏輯:
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
}
然后實現 HTTP 接口,對外提供預簽名 URL 獲取服務:
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")
}
5.2 前端 PUT 方式上傳實現
封裝三種 PUT 上傳方案(XMLHttpRequest/Fetch/Axios),并通過后端接口獲取預簽名 URL:
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);
});
}
}
5.3 前端 POST 方式上傳實現
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);
});
}
}
六、實戰(zhàn)踩坑指南
在實現過程中,容易遇到以下問題,整理解決方案如下:
6.1 PresignedPutObject 必須用 PUT 方法
PresignedPutObject 生成的預簽名 URL 僅支持 PUT 方法,若使用 POST 方法上傳會直接失敗。需嚴格匹配 API 定義的請求方法。
6.2 PUT 上傳無需構造 FormData
部分開發(fā)者會習慣性構造 FormData 上傳文件,但 PresignedPutObject 方案不支持這種方式——FormData 會導致請求體包含額外的協(xié)議數據(如 ------WebKitFormBoundary 分隔符),MinIO 無法正確解析文件內容。
正確做法:直接將 File 對象作為請求體發(fā)送,無需封裝 FormData。
6.3 Axios 上傳需手動指定 Content-Type
XMLHttpRequest 和 Fetch API 會自動根據文件類型設置正確的 Content-Type,但 Axios 不會。若未手動指定 Content-Type: file.type,MinIO 會將文件 Content-Type 設為 Axios 默認的 application/x-www-form-urlencoded,導致文件無法正常預覽。
6.4 POST 上傳時 file 表單域必須在最后
使用 PresignedPostPolicy 方案時,FormData 中的 file 字段必須放在所有表單數據的最后一位。否則會報以下錯誤:
The body of your POST request is not well-formed multipart/form-data # 或 The name of the uploaded key is missing
原因:MinIO 對 POST 表單數據的解析順序有嚴格要求,file 字段需作為最后一個參數提交。
6.5 403 錯誤:主機名不匹配
PUT 上傳時出現 403 錯誤,大概率是預簽名 URL 中的主機名與 MinIO 服務的主機名不匹配。核心原因:
MinIO 預簽名 URL 會將主機名(host)納入簽名驗證范圍(對應 X-Amz-SignedHeaders: host)。若后端連接 MinIO 使用的 endpoint(如 localhost:9000)與前端實際訪問的 MinIO 地址(如 192.168.1.100:9000)不一致,會導致簽名驗證失敗。
解決方案:
- 后端連接 MinIO 時,使用前端可訪問的地址(如外網 IP 或域名)作為 endpoint;
- 通過環(huán)境變量
MINIO_SERVER_URL和MINIO_BROWSER_REDIRECT_URL綁定 MinIO 服務的域名/IP:MINIO_SERVER_URL:指定 API 服務地址(默認9000端口); MINIO_BROWSER_REDIRECT_URL:指定控制臺地址(默認9001端口);- 注意:必須添加
http://或https://前綴,例如http://minio.example.com:9000。
Docker 部署時,可通過 --env 參數注入這兩個環(huán)境變量(參考本文第二部分的 Docker 啟動命令)。
七、示例代碼倉庫
本文完整示例代碼已上傳至 Github 和 Gitee,包含后端 Go + Gin 實現,以及前端 React、Vue 兩種框架的上傳示例(支持進度條、多文件上傳等擴展功能):
- Github:https://github.com/tx7do/minio-typescript-example
- Gitee:https://gitee.com/tx7do/minio-typescript-example
到此這篇關于JavaScript/TypeScript 前端實現文件上傳到 MinIO 完整指南的文章就介紹到這了,更多相關JavaScript/TypeScript 文件上傳到MinIO內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
基于JS正則表達式實現模板數據動態(tài)渲染(實現思路詳解)
這篇文章主要介紹了基于JS正則表達式實現模板數據動態(tài)渲染 ,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-03-03

