我有一个朋友,前端,最近在找工作,面试官就问了他,对oss了解吗,他没回答上来,于是就有了这篇文章...
介绍
本文简介
- 本文使用react实现前端,node的express框架实现后端,搭配开源的minio
- 实现一个oss文件存储服务功能
- 有助于前端更好地理解文件存储的过程
- 完整项目代码:github.com/shuirongshu...
效果图

功能有:上传文件、查询文件列表、删除文件、下载文件
等
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,
- 适合私有云、公有云或混合云环境
- 常用于存储非结构化数据(如图片、视频、日志文件等)。
下载安装

- 下载好以后,把下载的
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,没问题了
需要用到的包
js
{
"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", // 文件上传专用库
}
}
引入包等相关准备
js
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存在
js
// 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的桶
上传文件接口
js
// 文件上传接口
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字符集指定一下
js
// 文件名编码处理函数
function decodeFileName(originalname) {
try {
// 尝试使用 Buffer 进行解码
return Buffer.from(originalname, 'latin1').toString('utf8');
} catch (error) {
console.error('文件名解码错误:', error);
return originalname;
}
}
文件列表查询接口
调用minio的listObjects方法,查询列表
js
// 获取文件列表接口
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方法
js
// 删除文件接口
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: '文件删除失败' });
}
});
获取文件下载链接接口
js
// 获取文件下载链接接口
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';
js
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组件展示操作文件
接口如图:
定义数据接口,参照上图:
ts
interface recordType {
name: string;
size: number;
lastModified: string;
url: string;
}
定义表格列,参照第一张效果图
js
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>
),
},
];
发请求操作数据
js
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题目的公司,挂了,最近还在继续面试呢...