动态

详情 返回 返回

面試官:你是前端你瞭解oss嗎?我反手寫了一個react+express+minio實現oss文件存儲功能 - 动态 详情

我有一個朋友,前端,最近在找工作,面試官就問了他,對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>&nbsp;&nbsp;
      <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題目的公司,掛了,最近還在繼續面試呢...
user avatar shuirong1997 头像 risejl 头像 dalidexiaoxiami 头像 bigegaodeci 头像 user_cwhpnk0j 头像 dushigemi 头像 shoyuf 头像 dragonir 头像 delia_5a38831addb7b 头像 maomaoxiaobo 头像 xiayifeifandewudongmian 头像 huobaodechahu 头像
点赞 19 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.