面试官:你是前端你了解oss吗?我反手写了一个react+express+minio实现oss文件存储功能

我有一个朋友,前端,最近在找工作,面试官就问了他,对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>&nbsp;&nbsp;
      <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题目的公司,挂了,最近还在继续面试呢...
相关推荐
wordbaby29 分钟前
React Router 中调用 Actions 的三种方式详解
前端·react.js
wordbaby1 小时前
后端的力量,前端的体验:React Router Server Action 的魔力
前端·react.js
wordbaby1 小时前
让数据“流动”起来:React Router Client Action 与组件的无缝协作
前端·react.js
宁静_致远1 小时前
React 性能优化:深入理解 useMemo 、useCallback 和 memo
前端·react.js·面试
栀一一2 小时前
webpack和create-react-app的关系
react.js
有仙则茗2 小时前
process.cwd()和__dirname有什么区别
前端·javascript·node.js
盛夏绽放5 小时前
Node.js 路由请求方式大全解:深度剖析与工程实践
node.js·有问必答
GISer_Jing5 小时前
React前端与React Native移动端开发须知差异
前端·react native·react.js
EndingCoder5 小时前
React Native 与后端协同开发指南
javascript·react native·react.js