Yuanlin Lin

Blog

為什麼你應該使用 OSS(物件存儲服務)而不是直接存在本地?

Yuanlin Lin 林沅霖

2023-10-06

最近身邊有許多朋友剛學習了一些後端開發的基本概念,迫不及待開始做了許多很有意思的專案,而當他們準備為他們的專案的後端加入儲存檔案的功能時,他們都會遇到一個問題:我該如何儲存使用者上傳的檔案?

例如今天我們開發了一個論壇系統,用 Node.js 開發後端並使用 MongoDB 儲存資料,當使用者發布一篇文章,我們就把文章存進資料庫;當其他使用者想看這篇文章,我們就把文章從資料庫查詢出來。

前端發請求給 Node.js 後端,後端把資料存進 MongoDB

但是如果我們想讓這個論壇系統更加豐富,例如上傳的文章可以帶有許多豐富的圖片,那我們該怎麼做呢?

1. 直接把圖片存在資料庫

許多入門的後端開發者(包括剛入門的我)都會想到一個直接且暴力的作法:把圖片存在資料庫

既然我們把文章存在資料庫,那圖片作為文章的一部分,直接存在資料庫的話也不是不行。這樣我們既節省了修改程式碼的時間,也不用再去學習其他的技術,一舉兩得。

我們只要把上傳上來的圖片進行一次 Base64 編碼,然後把編碼後的字串存進資料庫:

當我們要把文章從資料庫查詢出來時,再把 Base64 編碼的字串解碼,就可以得到原本的圖片了(甚至連解碼都不用,直接把 Base64 編碼的字串放進 <img> 標籤的 src 屬性中,瀏覽器就會自動解碼並顯示圖片)。

{ "title": "這是一篇文章", "content": "這是一篇文章的內容", "image": "data:image/png;base64,iVB..." }

但這樣做有一個很大的問題:資料庫的效能會變得很差。因為資料庫本身是用來儲存結構化資料的,讓我們能夠快速從大量的結構化資料中找出我們需要的資料。如果我們把圖片這種非結構化的資料也塞進去資料庫,會導致資料庫中的每一筆資料都非常巨大且複雜,讓資料庫的效能變得很差。

2. 直接把圖片存在伺服器

既然把圖片存在資料庫不行,那我們就把圖片存在伺服器好了。

把上傳的圖片直接存在本地的某個資料夾

我們可以在伺服器上建立一個資料夾,當使用者上傳圖片時,我們就把圖片存在這個資料夾中,然後把圖片的路徑存在資料庫中。當我們要把文章從資料庫查詢出來時,再把圖片的路徑取出來,就可以得到原本的圖片了。

{ "title": "這是一篇文章", "content": "這是一篇文章的內容", "image": "/images/1.png" }

為了實現這個方法,我們需要實作一個把圖片上傳到伺服器的功能:

import fs from 'fs'; function uploadImage(image) { fs.writeFileSync(`/images/${image.name}`, image.data); } app.post('/upload', (req, res) => { // ... 把文章存進資料庫 uploadImage(req.body.image); res.json({ success: true }); });

然後當前端觀看文章時,我們會把這個文章在伺服器本地的路徑放進 <img> 標籤的 src 屬性中:

app.get('/article/:id', (req, res) => { const article = getArticle(req.params.id); res.send(` <h1>${article.title}</h1> <p>${article.content}</p> <img src="${article.image}"> `); });

這樣當使用者觀看文章時,瀏覽器就會從伺服器上取得圖片,然後我們需要讓伺服器把對應的圖片傳給瀏覽器:

app.get('/images/:name', (req, res) => { res.sendFile(`/images/${req.params.name}`); });

這樣我們就完成了把圖片存在伺服器的功能了。這可能是一個不錯的方法,甚至是很多人目前的作法,但是這樣做還是有一些問題:

後端服務不再是無狀態了

所謂的無狀態就是指這個服務本身對於使用者的狀態是不知道的,也不會去記住使用者的狀態,所以我們可以毫無壓力的對他進行重啟或複製。

服務的無狀態(Stateless)和有狀態(Steateful)是新手容易困惑的觀念。圖片來源:https://www.kubernetic.com/blog/demystifying-statefulsets

舉一個簡單的例子:如果今天我們的服務是無狀態的,那麼當我們需要搬移我們的服務到另一台伺服器時,我只需要在另一個伺服器啟動一個一樣的服務,然後把域名指向新的伺服器,然後再關閉舊的服務,就在使用者完全沒有感覺到的情況下完成了服務的搬移。

但如果我們把檔案直接存在伺服器本地的資料夾,那當我們搬移服務的時候,馬上就會發現一個問題:新的伺服器裡面沒有以前使用者上傳的檔案!這時我們就需要把舊的伺服器裡面的檔案複製到新的伺服器裡面,這樣才能讓新的伺服器正常運作。但如果我們複製的過程中又有人上傳了新的檔案怎麼辦呢?我們又要再複製一次嗎?這樣的話我們的服務就會變得非常難維護。

新的伺服器裡面沒有以前的人上傳的圖片

再舉另一個例子:當我們的論壇服務經營的很好,越來越多人使用,我們會希望可以讓我們的服務變得更加穩定,這時我們可以把服務複製成多個服務,然後放在不同的伺服器上,這樣就可以讓我們的服務變得更加穩定且不容易被攻擊。

這個時候就發生了問題:如果我們把檔案直接存在伺服器本地的資料夾,那某個檔案就會只存在於其中一台伺服器中,這時就會發生「薛丁格的圖片」的情況,因為這張圖片處於「存在」和「不存在」的兩種狀態中,只有運氣好的使用者才能看到這張圖片,這樣的服務怎麼能夠穩定運作呢?

每重新整理三次就有一次機會能看到這張圖片(?)

3. 使用 OSS(物件存儲服務)

OSS 的全名是 Object Storage Service,是一種專門用來儲存物件的服務,物件可以是任何形式的資料,例如圖片、影片、音樂、檔案等等。

對於第一次聽到這個概念的讀者,我們可以很直觀的把 OSS 想像成是一個專門用來給程式使用的「雲端硬碟」,當我們的後端服務需要儲存檔案,我們就把檔案放進這個雲端硬碟,然後當我們的後端服務需要讀取檔案時,我們就從這個雲端硬碟讀取檔案。

OSS 讓我們的服務又變回無狀態的,太好了!

如此一來,我們就可以把檔案從伺服器本地的資料夾中移除,讓我們的後端服務變得無狀態,這樣我們就可以輕鬆的複製我們的後端服務,或是把我們的後端服務搬移到其他的伺服器上,而不用擔心檔案的問題。

常見的 OSS 服務

目前市面上有許多的 OSS 服務,例如:

這些服務都提供了一個簡單的 API 讓我們可以很容易的把檔案上傳到雲端硬碟中,並且提供了一個簡單的網址讓我們可以從雲端硬碟中讀取檔案。

值得一提的是,因為 S3 是最早被大家廣泛使用的雲端 OSS 服務,也是目前最多人使用的 OSS 服務,所以經常有人直接把 S3 當成 OSS 的代名詞,甚至很多其他 OSS 服務都強調自己是「兼容於 S3 的 API」的。

這是一個開發者樂見的趨勢,因為當行業慢慢以 S3 的 API 作為 OSS 的標準規範以後,我們的程式碼就不會被 AWS 給綁定,只要簡單改一下服務的設定,我們就可以輕鬆的把程式碼從 AWS 轉移到其他的雲端服務上。

除了上面提到的公有雲 OSS 服務以外,也有例如 MinIO 這樣的開源專案,讓我們能夠私有化部署一個自己的 OSS 服務。

MinIO:開源、可私有化部署且 S3 相容的 OSS 服務

範例

假設我們今天決定用 S3 來儲存使用者上傳到論壇的圖片,我們可以使用 aws-sdk 這個套件來實作上傳圖片的功能:

import AWS from 'aws-sdk'; const s3 = new AWS.S3({ accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, region: process.env.AWS_REGION, }); function uploadImage(image) { s3.upload({ Bucket: process.env.AWS_BUCKET_NAME, Key: image.name, Body: image.data, }); } app.post('/upload', (req, res) => { // ... 把文章存進資料庫 uploadImage(req.body.image); res.json({ success: true }); });

注意到 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY 是你的 AWS 帳號的存取金鑰,你必須有一個 AWS 帳號才能夠使用 S3 服務,你可以在 AWS 的 IAM 控制台中建立一個 IAM 使用者,然後把這個使用者的存取金鑰放進你的程式碼中。

當我們把檔案存進 OSS 以後,有兩個方法可以讓使用者瀏覽這些檔案:

  1. 直接給前端 OSS 裡面對應的檔案存取連結。
  2. 給前端一個後端的 API 連結,然後在後端的 API 中把檔案從 OSS 中讀取出來,然後回傳給前端。

第一種方法比較簡單,但如果我們希望能夠限制使用者只能夠存取自己的檔案,或是限制使用者只能夠存取特定的檔案,那就會遇到一些問題。雖然 OSS 服務都有實現很完整的存取權限管理的策略,但對象是「OSS 的使用者」而不是「我們的使用者」,換句話說對 S3 而言,他只在乎「某個 AWS 使用者」有沒有權限存取這個檔案,而不在乎「某個我們論壇的使用者」有沒有權限存取這個檔案。

因此,我們可以使用第二種方法,讓前端只能夠透過後端的 API 存取檔案,這樣我們就可以在後端的 API 中實現存取權限的管理。

app.get('/images/:name', (req, res) => { // ... 檢查使用者是否有權限存取這個檔案 const stream = s3.getObject({ Bucket: process.env.AWS_BUCKET_NAME, Key: req.params.name, }).createReadStream(); stream.pipe(res); });

範例二:在 Zeabur 部署 MinIO 服務

想要用 S3 來儲存的話,你首先必須要先有一個 AWS 帳號,然後在 AWS 的 S3 控制台中建立一個 S3 Bucket,然後把這個 Bucket 的名稱、存取金鑰等等資訊放進你的程式碼中。

這個流程對於想要快速開發的開發者,或是剛入門正在學習的人來說可能會覺得有點麻煩,這時我們可以選擇直接在 Zeabur 上面部署一個 MinIO 服務,然後直接使用 MinIO 服務的 API 來儲存檔案。

在 Zeabur 一鍵部署 MinIO,簡單、便宜還能優化速度!

如果你的後端服務也同樣是部署在 Zeabur 上的話,那你的 MinIO 和後端服務就能夠直接走內部網絡來傳入資料,降低延遲及流量成本。

相關範例可以參考 zeabur/express-minio-example 這個 GitHub 儲存庫。

結論

在這篇文章中,我們介紹了為什麼我們應該使用 OSS 服務來儲存檔案,而不是直接存在伺服器本地。我們也介紹了如何使用 S3 服務來儲存檔案,並且介紹了如何在 Zeabur 上部署 MinIO 服務來儲存檔案。

希望未來能夠看到更多的開發者使用 OSS 服務來儲存檔案,尤其是剛入門後端開發的開發者,能在學習的過程中就掌握正確的概念,開發出更加穩定且易於維護的服務!

分享你的看法

暫無留言,你可以成為第一個留言的人!

author-avatar

關於作者

Yuanlin Lin 林沅霖

台灣桃園人,目前就讀浙江大學,主修計算機科學與技術,同時兼職外包全端開發工程師,熱愛產品設計與軟體開發。