iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Modern Web

Golang x Echo 30 天:零基礎GO , 後端入門系列 第 26

以 Go + Echo 打造部落格|第 10 集:圖片上傳與封面處理

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20251010/201788185cARKKRdiw.jpg

我們的目標:打造一個超穩的圖片上傳系統!先讓圖能乖乖存在我們電腦裡,未來就算想搬家到 S3 這種超大的雲端倉庫,也只要換個零件就好,程式碼不用大改


這次我們要做甚麼功能?

簡單講,我們要蓋一個「圖片收發中心」,它有以下幾個基本規矩:

櫃檯服務:後台要有一個漂亮的「上傳圖片」頁面,讓你可以選圖、上傳。

檔案警衛:只收 JPEG、PNG、WebP 這三種「良民圖」,而且每張圖的體重不能超過 5MB(不然伺服器會被撐爆啦!)。

檔名變身術:把大家亂取的檔名(像 我家貓咪.jpg)通通換成一串獨一無二的 UUID(就像發給它一張永久身分證),這樣絕對不會跟別人的圖「撞衫」!

封面設定:把這張圖的網址,寫到文章的資料庫裡,它就是這篇文章的**「封面擔當」**。

未來換引擎:我們訂一個 Storage 介面(想像成一個萬用規格書)。現在先用本機儲存 (LocalStorage) 應急,以後想換 S3?免驚!換個符合規格書的「引擎」就好!


  1. 專案裡的「厝邊隔壁」多了誰?
    你的專案樹變長了,但別怕,重點就是多了跟 storage (倉庫) 和 uploads (收發貨) 有關的檔案:
go-echo-blog/
├─ cmd/server/main.go                     # 掛 Body 限制、注入 Storage
├─ internal/http/handlers/
│  ├─ admin_uploads.go                    # 後台 UI:上傳頁/表單
│  ├─ api_uploads.go                      # 後台 API:處理上傳(受 Session 保護)
│  └─ admin_posts.go                      # 文章表單支援選擇封面
├─ internal/storage/
│  ├─ storage.go                          # 介面:Save/URL/Delete
│  └─ local/local.go                      # 本機儲存實作
├─ internal/storage/util/safe_name.go     # 產生安全檔名(UUID + 副檔名)
├─ internal/storage/types.go              # 媒體描述型別
├─ migrations/
│  └─ 20251010_add_cover_image.sql        # 新增 posts.cover_image 欄位
├─ web/templates/pages/
│  ├─ admin_uploads.html                  # 上傳頁面
│  └─ admin_posts_form_partial.html       # 新增「封面 URL」欄位
└─ .env.example                           # UPLOAD_DIR、MAX_UPLOAD_MB

  1. .env.example:設定你的倉庫
    就像你要跟貨運公司說:「我要把東西寄到哪裡?包裹最重能多重?」一樣。
# 檔案會存到這個資料夾(跑 server 的機器上)
UPLOAD_DIR=./uploads
# 檔案大小上限(MB)
MAX_UPLOAD_MB=5

  1. 資料庫:幫文章找個「封面照」的位置
    要讓文章知道它的封面是哪一張,就要在資料表裡留個位置。我們加一個 cover_image 欄位來存圖片的網址。

migrations/20251010_add_cover_image.sql

-- +goose Up
ALTER TABLE posts ADD COLUMN IF NOT EXISTS cover_image TEXT;

-- +goose Down
ALTER TABLE posts DROP COLUMN IF EXISTS cover_image;

若你之前就有 cover_image,這段會自動略過(IF NOT EXISTS)。


  1. 關鍵靈魂:Storage 介面(規格書)
    這就是這次的 MVP 核心魔法!我們規定所有「儲存服務」都必須會這三招,以後想從本機換 S3,只要新的服務也滿足這三招就好。

internal/storage/storage.go 說:

「不管你是誰,你都要會這三招!」

internal/storage/storage.go

package storage

import "context"

type ObjectInfo struct {
	Name string // 儲存後的檔名(不含路徑)
	URL  string // 對外可取用的 URL(本機可用 /uploads/...)
	Size int64
	MIME string
}

type Storage interface {
	Save(ctx context.Context, filename string, data []byte, mime string) (ObjectInfo, error)
	Delete(ctx context.Context, filename string) error
	URL(ctx context.Context, filename string) (string, error)
}

internal/storage/types.go

package storage

var AllowedMIMEs = map[string]bool{
	"image/jpeg": true,
	"image/png":  true,
	"image/webp": true,
}

internal/storage/util/safe_name.go

package util

import (
	"path/filepath"
	"strings"

	"github.com/google/uuid"
)

// SafeFileName 以 UUID 生成安全檔名,並保留合法副檔名
func SafeFileName(orig string) string {
	ext := strings.ToLower(filepath.Ext(orig))
	switch ext {
	case ".jpg", ".jpeg", ".png", ".webp":
	default:
		ext = ".jpg" // 預設給 .jpg
	}
	return uuid.NewString() + ext
}

然後,為了檔名安全和副檔名檢查,我們還有兩位小幫手:

  • safe_name.go (檔名身分證產生器):把 我的可愛貓咪.jpg 變成 9527e02e-c75c-4a37-8b01-f8a425f38a5b.jpg,保證不撞名!

  • types.go (圖片種類檢查員):它只讓 .jpg, .png, .webp 的圖進來,其他像是 .exe 或奇怪的檔案? 「歹勢,請回!」


  1. LocalStorage:先讓東西存到你家!
    這是我們第一個「實踐」Storage 規格書的服務,功能就是乖乖把圖片存到你設定的那個 UPLOAD_DIR 資料夾裡。

對外提供檔案:記得在 cmd/server/main.go/uploads 靜態出來。
internal/http/handlers/api_uploads.go

func (s *LocalStorage) Save(...) (...) {
    // 1. 先確認資料夾有沒有,沒有就建一個。
    // 2. 接著把檔案寫進去。
    // 3. 算出它的公開網址,例如 /uploads/9527.jpg
    // 4. 回傳成功!
}
// ... Delete 和 URL 也很簡單,就是刪除檔案和組合網址而已。

⚠️ 超重要! 圖片存在電腦裡,但網頁看不到啊!所以要在 cmd/server/main.go 裡設定:只要有人連到 /uploads 這個網址,就去我們本機的 UPLOAD_DIR 資料夾裡撈圖給他看!這就是「靜態檔案服務」。


  1. 後台上傳 API:收包裹的櫃檯
    這就是真正處理上傳請求的地方,我們請 UploadsHandler 來當櫃檯小姐/先生。

internal/http/handlers/api_uploads.go 裡的 Post 函式就是它的工作流程:

📥 收件!:先確認你是不是真的有選圖,有沒有叫 file 這個名字。

⚖️ 量體重!:檢查這張圖有沒有超過 5 MB(MAX_UPLOAD_MB)。「唉唷,夭壽喔!太重了!」就直接拒收!

🔍 驗身分!:檢查檔案的 MIME(像是它偷偷藏在檔案頭裡的真實身分證),確認它是我們允許的 JPEG/PNG/WebP 嗎?「你不是圖?掰掰!」

🪪 發身分證!:把原來的檔名丟掉,用 UUID 重新取一個獨一無二的安全檔名。

📦 送入庫!:呼叫 Store.Save(),把圖交給我們的「小幫手」(目前是 LocalStorage)存起來。

📬 回報!:成功後,把圖片的新檔名和網址回傳給前端頁面。

internal/http/handlers/api_uploads.go

package handlers

import (
	"bytes"
	"io"
	"net/http"
	"os"
	"strconv"

	"your/module/internal/storage"
	"your/module/internal/storage/util"

	"github.com/labstack/echo/v4"
)

type UploadsHandler struct {
	Store storage.Storage
	MaxMB int64
}

func NewUploadsHandler(store storage.Storage) *UploadsHandler {
	maxMB, _ := strconv.ParseInt(os.Getenv("MAX_UPLOAD_MB"), 10, 64)
	if maxMB <= 0 { maxMB = 5 }
	return &UploadsHandler{Store: store, MaxMB: maxMB}
}

func (h *UploadsHandler) Post(c echo.Context) error {
	fh, err := c.FormFile("file")
	if err != nil {
		return c.JSON(http.StatusBadRequest, echo.Map{"error": "沒有收到檔案 field: file"})
	}
	if fh.Size > h.MaxMB*1024*1024 {
		return c.JSON(http.StatusRequestEntityTooLarge, echo.Map{"error": "檔案太大"})
	}
	f, err := fh.Open()
	if err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"error": "檔案讀取失敗"}) }
	defer f.Close()

	var buf bytes.Buffer
	if _, err := io.Copy(&buf, f); err != nil {
		return c.JSON(http.StatusInternalServerError, echo.Map{"error": "上傳失敗"})
	}
	data := buf.Bytes()

	mime := http.DetectContentType(data[:min(512, len(data))])
	if !storage.AllowedMIMEs[mime] {
		return c.JSON(http.StatusBadRequest, echo.Map{"error": "只支援 JPEG/PNG/WebP"})
	}

	safe := util.SafeFileName(fh.Filename)
	obj, err := h.Store.Save(c.Request().Context(), safe, data, mime)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, echo.Map{"error": "儲存失敗"})
	}
	return c.JSON(http.StatusOK, echo.Map{"filename": obj.Name, "url": obj.URL, "size": obj.Size, "mime": obj.MIME})
}

func min(a, b int) int { if a < b { return a }; return b }

  1. 頁面展示:把檔案傳出去!
    web/templates/pages/admin_uploads.html 裡面就是一個簡單的 form 表單 和一小段 JavaScript:

選圖:<input type="file" ...>

按鈕:上傳

JavaScript:按下按鈕後,會把圖片包進 FormData 裡,然後 fetch 給我們第 6 點做的 API 櫃檯。成功就會在 裡看到回傳的 JSON 資訊!

web/templates/pages/admin_uploads.html

{{ define "content" }}
<h1 class="text-xl font-bold mb-4">上傳圖片</h1>
<form id="upload-form" class="space-y-4">
  <input type="file" id="file" name="file" accept="image/*" class="border p-2">
  <button type="button" id="btn" class="bg-black text-white px-4 py-2 rounded">上傳</button>
</form>
<pre id="result" class="mt-4 text-xs bg-gray-100 p-2 rounded"></pre>
<script>
document.getElementById('btn').addEventListener('click', async () => {
  const f = document.getElementById('file').files[0];
  if (!f) return alert('請選擇一張圖片');
  const fd = new FormData();
  fd.append('file', f);
  const res = await fetch('/api/admin/uploads', { method: 'POST', body: fd, credentials: 'include' });
  document.getElementById('result').textContent = JSON.stringify(await res.json(), null, 2);
});
</script>
{{ end }}

  1. 封面設定與顯示
    最後,把上傳成功拿到的 URL 網址,填到文章編輯表單的 cover_image 欄位,存進資料庫。

後台:文章編輯表單多一個 封面 URL 的 欄位。

前台:文章顯示頁 (post_show.html) 看到 cover_image 有東西,就用 把它秀出來,當作文章封面!

web/templates/pages/admin_posts_form_partial.html

<label class="block text-sm mb-2">封面 URL</label>
<input type="text" name="cover_image" value="{{ .post.CoverImage }}" class="border p-2 w-full" placeholder="/uploads/xxx.jpg">

web/templates/pages/post_show.html

{{ if .post.CoverImage }}
  <img src="{{ .post.CoverImage }}" alt="cover" class="mb-4 w-full max-h-96 object-cover rounded">
{{ end }}

  1. 總結一下主程式
    在 cmd/server/main.go 裡,我們做了兩件大事:

cmd/server/main.go(節錄)

// …前略
uploadDir := os.Getenv("UPLOAD_DIR")
if uploadDir == "" { uploadDir = "./uploads" }
e.Static("/uploads", uploadDir)

store := local.New(uploadDir, "/uploads")
up := handlers.NewUploadsHandler(store)
e.POST("/api/admin/uploads", up.Post)
e.GET("/admin/uploads/new", adminUploads.New)
// …後略

10.未來要換 S3?
你可能會問:「搞那麼複雜幹嘛?」

因為我們只換「輪胎」,不用換「車」!

未來某天,老闆說:「流量爆炸了!本機存圖快掛了,全部給我搬到 S3 雲端倉庫!」

你只要:

寫一個 s3.go,它也要符合我們訂的 Storage 規格書 (會 Save, Delete, URL)。

修改 cmd/server/main.go 裡,第 9 點的第 2 步驟,讓程式跑起來時,用 S3 取代 local.New(...)!

搞定!你的上傳 API (api_uploads.go) 一個字都不用改,因為它只認識規格書 (Storage),不認識實作!

11. 疑難雜症救星:上傳失敗免驚!

💥 狀況一:網站回傳 413 Request Entity Too Large
意思是什麼? 你的檔案太大了,大到伺服器都嚇一跳,直接拒絕接收你的請求!

你該怎麼辦?

先去調整門檻: 檢查 .env 檔案,把 MAX_UPLOAD_MB 的值調大一點。

不然就減肥: 把你的圖片用軟體或網站先壓縮、縮小一點再傳!

💥 狀況二:網站回傳 415 Unsupported Media Type 或 400 Bad Request
意思是什麼?

415:你傳的檔案「身分不對」,不是我們說好的 JPEG/PNG/WebP。你是不是想偷傳 .exe 或 .zip?

400:你根本沒選圖,或傳送表單時「file」這個欄位名字寫錯了!

你該怎麼辦?

檢查檔案: 確定你傳的是圖片,不要亂塞奇怪格式的東西!

檢查欄位: 回頭看 api_uploads.go,是不是有正確的 c.FormFile("file") 在接收?

💥 狀況三:上傳成功了,但文章頁面圖片顯示 404 Not Found
意思是什麼? 圖片有存到你的電腦裡,但網頁瀏覽器找不到它!

你該怎麼辦?

檢查靜態服務: 這個最重要!你是不是忘了在 cmd/server/main.go 裡,告訴網站:「只要有人連 /uploads 這個網址,就去我硬碟的 ./uploads 資料夾撈圖?」請確定你有寫 e.Static("/uploads", uploadDir) 這一行!

檢查網址: 看看資料庫存的 cover_image 網址對不對?是不是 /uploads/xxx.jpg?開頭的 / 有沒有漏掉?

💥 狀況四:資料庫有網址,但前台文章沒顯示封面
意思是什麼? 圖片都存好了,網址也寫進資料庫了,但網頁模板沒寫好。

你該怎麼辦?

檢查模板: 回頭看 post_show.html,你是不是有正確寫上 {{ if .post.CoverImage }} ... 這些判斷式?如果資料庫欄位是空的,它當然不會顯示出來!



上一篇
以 Go + Echo 打造部落格|第 9 集:後端餐廳的出包 SOP
下一篇
以 Go + Echo 打造部落格|第 11 集 RSS、Sitemap、SEO Meta:讓搜尋引擎找到你
系列文
Golang x Echo 30 天:零基礎GO , 後端入門29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言