我有一個朋友,前端,最近在找工作,面試官就問了他,對oss瞭解嗎,他沒回答上來,於是就有了這篇文章...
介紹
本文簡介
- 本文使用react實現前端,node的express框架實現後端,搭配開源的minio
- 實現一個oss文件存儲服務功能
- 有助於前端更好地理解文件存儲的過程
- 完整項目代碼:https://github.com/shuirongshuifu/react-node-minio
效果圖
功能有:上傳文件、查詢文件列表、刪除文件、下載文件等
minio
什麼是oss
- OSS(Object Storage Service) ,中文叫 對象存儲服務,是一種專門用來存儲和管理海量 非結構化數據(比如圖片、視頻、文檔、日誌等)的雲存儲服務。
- 我們可以把它想象成一個“無限容量的雲硬盤”,但它不是像電腦硬盤那樣用文件夾分類文件,而是用 唯一的文件名(Key) 來存取數據。
- OSS 存儲的是 對象(Object) ,每個文件會被分配一個 唯一的Key(比如
user123/photo/2024/vacation.jpg),沒有複雜的目錄層級。 - 能存海量數據,強的可怕!
- 就是用户上傳的照片/視頻 → 存到 OSS(節省服務器空間,高速分發)。
- APP訪問這些圖片 → 直接通過OSS的鏈接(如
https://xxx.oss-aliyun.com/user1/photo.jpg)加載。 - 我們熟知的百度網盤,背後就是OSS技術哦
什麼是minio
通俗易懂的理解:
- 我們知道,數據要存儲,若是簡單的數據,比如姓名、年齡、家鄉等數據可以存儲在數據庫中
- 比如
MySql、Oracle等數據庫中 - 但是,日常的需求,還需要存儲一些諸如圖片、視頻、音頻等文件
- 這個時候
oss就派上用場了(專門為文件存儲而生的服務) - oss有收費的,和開源免費的
- 比如
阿里雲、騰訊雲都提供了對應的收費oss服務 - 但是小企業考慮到成本,可能會選擇開源的、免費的
oss,比如説minio 不是阿里雲和騰訊雲買不起,而是minio更具性價比😏😏😏
高大上的介紹:
- MinIO 是一個高性能、開源的 對象存儲 解決方案
- 專為大規模數據存儲和檢索設計。它兼容 Amazon S3 API,
- 適合私有云、公有云或混合雲環境
- 常用於存儲非結構化數據(如圖片、視頻、日誌文件等)。
下載安裝
- 下載地址:https://www.minio.org.cn/download.shtml#/windows
- 下載好以後,把下載的
minio.exe程序,放在文件夾下 - 比如,這裏我把
minio.exe程序,放在我的C盤下的,新建的minio文件夾裏面 - 然後在當前目錄下,使用cmd打開命令行,並輸入命令
minio.exe server C:\minio\data - 意思是,
minio的服務,啓動在C盤下的minio文件夾裏面,所有上傳的文件存儲在這個目錄的data文件夾中 - 不需要在
minio文件夾下,再創建data文件夾了,上述命令會幫我自行創建data文件夾 - 如下圖:
- 當,執行
minio.exe server C:\minio\data後 - 出現如下圖,就表明啓動成功了
- 然後,在瀏覽器中,輸入本地ip加端口,
minio默認9000端口 http://127.0.0.1:9000- 就可以訪問到
minio的後台UI服務了 - 注意,
minio的接口端口默認9000,但是後台UI服務若不指定,就會隨機分配端口 - 這裏我們不用刻意去指定端口,當訪問9000端口,
minio會自動重定向到後台UI服務的端口的 - 用户名和默認密碼都是
minioadmin - 如下圖:
- 登錄以後,如下圖,點擊
Buckets去創建桶
- 把桶設置為公開的,
Public,方便我們接口訪問
- 到這一步,我們就可以通過接口訪問了
後端Express服務
- 這裏的流程,就是前端上傳文件,調後端接口
- 後端調用minio的服務,把前端給到的文件存到minio中
- 存儲成功以後,再告訴前端,接口200,沒問題了
需要用到的包
{
"dependencies": {
"body-parser": "^1.20.3", // 解析前端請求體中帶過來的參數
"cors": "^2.8.5", // 放開接口跨域
"dayjs": "^1.11.13", // 格式化日期庫
"express": "^4.21.1", // node的老牌框架
"minio": "^7.1.3", // minio提供的包
"multer": "^1.4.5-lts.1", // 文件上傳專用庫
}
}
引入包等相關準備
const express = require('express');
const bodyParser = require('body-parser');
const multer = require('multer');
const Minio = require('minio');
const dayjs = require('dayjs');
const cors = require('cors');
const app = express();
const port = 19000; // 把後端服務啓動在19000端口
app.use(cors({ origin: '*' })); // 放開跨域
const expoUrl = 'http://127.0.0.1:9000'; // minio提供的接口地址
// 文件上傳配置
const upload = multer({
limits: {
fileSize: 20 * 1024 * 1024, // 限制20MB
},
fileFilter: (req, file, cb) => {
// 這裏可以添加文件類型限制
cb(null, true);
},
});
// 中間件配置
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 服務端口啓動
app.listen(port, () => {
console.log(`服務器運行在 http://localhost:${port}`);
});
創建minio客户端示例、並確保bucket存在
// MinIO 客户端配置
const minioClient = new Minio.Client({
endPoint: '127.0.0.1',
port: 9000,
useSSL: false,
accessKey: 'minioadmin',
secretKey: 'minioadmin'
});
// 確保 bucket 存在
const bucketName = 'files';
minioClient.bucketExists(bucketName, function (err, exists) {
if (err) {
return console.log(err);
}
if (!exists) {
minioClient.makeBucket(bucketName, function (err) {
if (err) {
return console.log('創建 bucket 失敗:', err);
}
console.log('Bucket 創建成功');
});
}
});
bucket存在就直接使用,不存在就創建一個名為files的桶
上傳文件接口
// 文件上傳接口
app.post('/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: '沒有文件被上傳' });
}
console.log('req.file--->', req.file)
const decodedFileName = decodeFileName(req.file.originalname);
const fileName = dayjs().format('YYYYMMDDHHmmss') + '-' + decodedFileName
const metaData = {
// 若文件名以.txt結尾,則補充編碼為 text/plain; charset=utf-8 解決亂碼問題
'Content-Type': req.file.originalname.endsWith('.txt') ? 'text/plain; charset=utf-8' : req.file.mimetype,
'Content-Disposition': 'inline'
};
// 調用minio的putObject,把文件存儲進去
await minioClient.putObject(bucketName, fileName, req.file.buffer, metaData);
res.json({
success: true,
fileName: fileName,
message: '文件上傳成功'
});
} catch (error) {
console.error('上傳錯誤:', error);
res.status(500).json({ error: '文件上傳失敗' });
}
});
注意,這裏的originalname可能會亂碼,需要使用utf-8字符集指定一下
// 文件名編碼處理函數
function decodeFileName(originalname) {
try {
// 嘗試使用 Buffer 進行解碼
return Buffer.from(originalname, 'latin1').toString('utf8');
} catch (error) {
console.error('文件名解碼錯誤:', error);
return originalname;
}
}
文件列表查詢接口
調用minio的listObjects方法,查詢列表
// 獲取文件列表接口
app.get('/files', async (req, res) => {
try {
const files = [];
const stream = minioClient.listObjects(bucketName, '', true);
stream.on('data', function (obj) {
files.push({
name: obj.name,
size: obj.size,
lastModified: dayjs(obj.lastModified).format('YYYY-MM-DD HH:mm:ss'),
url: `${expoUrl}/${bucketName}/${obj.name}`
});
});
stream.on('end', function () {
// 按最後修改時間降序排序(最新的在前)
files.sort((a, b) => {
return new Date(b.lastModified) - new Date(a.lastModified);
});
res.json(files);
});
stream.on('error', function (err) {
console.error('獲取文件列表錯誤:', err);
res.status(500).json({ error: '獲取文件列表失敗' });
});
} catch (error) {
console.error('獲取文件列表錯誤:', error);
res.status(500).json({ error: '獲取文件列表失敗' });
}
});
根據文件名刪除對應的文件
調用minio的removeObject方法
// 刪除文件接口
app.delete('/files/:fileName', async (req, res) => {
try {
const fileName = req.params.fileName;
await minioClient.removeObject(bucketName, fileName);
res.json({
success: true,
message: '文件刪除成功'
});
} catch (error) {
console.error('刪除文件錯誤:', error);
res.status(500).json({ error: '文件刪除失敗' });
}
});
獲取文件下載鏈接接口
// 獲取文件下載鏈接接口
app.get('/files/:fileName', async (req, res) => {
try {
const fileName = req.params.fileName;
const url = `${expoUrl}/${bucketName}/${fileName}`;
res.json({
success: true,
url: url
});
} catch (error) {
console.error('獲取文件鏈接錯誤:', error);
res.status(500).json({ error: '獲取文件鏈接失敗' });
}
});
前端React代碼
使用Antd的Upload組件上傳文件
BASE_URL作為後端服務的接口地址,為:export const BASE_URL = 'http://127.0.0.1:19000';
interface UpContentProps {
updateList: () => void;
}
export default function UpContent({ updateList }: UpContentProps) {
const props: UploadProps = {
action: `${BASE_URL}/upload`, // 上傳的地址
showUploadList: false, // 是否展示文件列表
beforeUpload: (file) => { // 上傳前的文件大小控制
// 判斷文件大小
if (file.size > 1024 * 1024 * 10) {
message.error('文件大小不能超過10MB');
return false;
}
return true;
},
onChange(info) {
if (info.file.status !== 'uploading') {
// console.log('文件上傳中...');
}
if (info.file.status === 'done') {
message.success(`${info.file.name} 上傳成功`);
// 刷新列表
updateList();
} else if (info.file.status === 'error') {
message.error(`${info.file.name} 上傳失敗`);
}
},
};
return (
<div style={{ marginTop: '6px' }}>
<Button type='primary' icon={<ReloadOutlined />} onClick={() => updateList()} >刷新列表</Button>
<Upload {...props}>
<Button type="dashed" icon={<UploadOutlined />}>上傳文件</Button>
</Upload>
</div>
)
}
使用Antd的Table組件展示操作文件
接口如圖:
定義數據接口,參照上圖:
interface recordType {
name: string;
size: number;
lastModified: string;
url: string;
}
定義表格列,參照第一張效果圖
const columns: ColumnsType<recordType> = [
{
title: '序號',
key: 'index',
width: 80,
render: (_: any, __: any, index: number) => index + 1,
responsive: ['lg', 'md'], // 只在大屏和中屏顯示
},
{
title: '文件名',
dataIndex: 'name',
key: 'name',
render: (value: string, record: recordType) => <a href={record.url} target="_blank">{value}</a>,
},
{
title: '文件大小',
dataIndex: 'size',
key: 'size',
render: (value: number) => <span>{formatSize(value)}</span>,
sorter: (a: recordType, b: recordType) => a.size - b.size,
},
{
title: '上傳時間',
dataIndex: 'lastModified',
key: 'lastModified',
sorter: (a: recordType, b: recordType) => new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime(),
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
render: (_: any, record: recordType) => (
<Space size="middle">
<Button danger type="link" onClick={() => handleDelete(record.name)}>刪除</Button>
<Button type="link" onClick={() => handleDownload(record.name)}>下載</Button>
</Space>
),
},
];
發請求操作數據
const DownContent = forwardRef((_, ref) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const columns: ColumnsType<recordType> = [ ...... ]; // 表格列數據
useEffect(() => { getList() }, []) // 初始請求列表數據
const getList = () => {
setLoading(true);
fetch(`${BASE_URL}/files`)
.then(res => res.json())
.then(data => {
const formattedData = data.map((item: Record<string, any>, index: number) => ({
...item,
key: item.name || index, // 使用文件名或索引作為 key
}));
setData(formattedData);
}).finally(() => {
setLoading(false);
});
}
const handleDelete = (name: string) => {
Modal.confirm({
title: `確定刪除文件《${name}》嗎?`,
okText: '確定',
cancelText: '取消',
onOk: () => {
setLoading(true);
fetch(`${BASE_URL}/files/${name}`, {
method: 'DELETE',
}).then(res => res.json()).then(data => {
data.success && getList();
message.success('刪除成功');
}).finally(() => {
setLoading(false);
});
},
onCancel: () => {
console.log('取消');
},
});
}
const handleDownload = (name: string) => { // 同上fetch請求邏輯,不贅述... }
return (
<div style={{ marginTop: '20px' }}>
<Table loading={loading} columns={columns} dataSource={data} scroll={{ y: '72vh' }} pagination={{ pageSize: 10 }} />
</div>
)
});
export default DownContent;
- 至此,就可以實現對應的功能了
- 相信大家把代碼拉下來,並且看看,當面試官
- 補充:我朋友面試的,這個問oss題目的公司,掛了,最近還在繼續面試呢...