DEV Community

MeowRain
MeowRain

Posted on • Originally published at meowrain.cn

个人图床-Go实现

个人图床-Go实现

https://github.com/meowrain/img-bed-Go

使用到的框架: Gin

使用到的库: github.com/chai2010/webp

一.目录结构

image-20240916153755065

项目如何运行?

image-20240916162325834

什么是反向代理?

img

二.安装相关库

go get -u github.com/gin-gonic/gin
go get -u github.com/chai2010/webp
go get -u gopkg.in/yaml.v3
go get -u "github.com/gin-contrib/cors"
Enter fullscreen mode Exit fullscreen mode

三.配置读取-config/config.go

config文件夹中编写config.go

分段分析

我准备读取下面的配置文件

Domain: "http://127.0.0.1"
port: 8080
auth:
  token: xxx # demo
Enter fullscreen mode Exit fullscreen mode

这里需要分析一下我们的配置文件为什么这么写,Domain这个用来以后找你的图片,比如你准备把图床的网站域名设置为: https://pic.meowrain.cn,那么当你向http://your server ip:8080发送post请求上传图片后,会收到后端响应,响应给你的地址也就是https://pic.meowrain.cn/year/month/day/xxxx.webp

因此为了我们访问图片能访问到,你需要为你的webserver(比如nginx或者caddy)设置一个反向代理

那么我需要编写对应的结构体,在config.go

type Config struct {
    Domain stgring `yaml:"domain"`
    Port string `yaml:"port"`
    Auth struct {
        Token string `yaml:"token"`
    } `yaml:"auth"`
}
Enter fullscreen mode Exit fullscreen mode

然后我们需要一个公共变量供外部函数调用

var Data Config
Enter fullscreen mode Exit fullscreen mode

为了能在用户不编写config.yaml的时候程序也能正常运行,我们需要用到go的embed,方便把配置文件一并打包到二进制文件中

//go:embed config.yaml
var EmbeddedConfig embed.FS
Enter fullscreen mode Exit fullscreen mode

为了在程序启动时候能够读取配置文件,我们需要编写对应的init函数

这里简单介绍一下init函数

init() 函数是一种在Go语言中用于执行初始化操作的特殊函数。每个包可以包含多个 init() 函数,它们会在包被导入时按照顺序自动执行。init() 函数的调用时机为:

  1. 当包被导入时,init() 函数会按照导入的顺序自动执行。
  2. 同一个包中的多个 init() 函数按照编写的顺序执行。
func init() {
    var bytes []byte
    var err error
    bytes, err = os.ReadFile("config/config.yaml")
    if err != nil {
        fmt.Println("读取外部配置失败")
        bytes, err = EmbeddedConfig.ReadFile("config.yaml")
        if err != nil {
            log.Fatalf("Error reading embedded config file: %v", err)
        }
    }
    err = yaml.Unmarshal(bytes, &Data)
    if err != nil {
        log.Fatalf("Error parsing config file: %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

上面的init函数,我们使用os.ReadFile函数读取config.yaml文件中的内容到bytes数组中,

如果外部配置文件不存在,那么我们就调用打包进二进制文件的默认config.yaml文件,读取到bytes数组中,然后解析到Data公共变量中

然后我们使用yaml.Unmarshalconfig.yaml中的数据解析到Data公共变量中

image-20240916155252340

完整代码

package config

import (
    "embed"
    "fmt"
    "gopkg.in/yaml.v3"
    "log"
    "os"
)

type Config struct {
    Domain string `yaml:"domain"`
    Port   string `yaml:"port"`
    Auth   struct {
        Token string `yaml:"token"`
    } `yaml:"auth"`
}

//go:embed config.yaml
var EmbeddedConfig embed.FS

var Data Config

func init() {
    var bytes []byte
    var err error
    bytes, err = os.ReadFile("config/config.yaml")
    if err != nil {
        fmt.Println("读取外部配置失败")
        bytes, err = EmbeddedConfig.ReadFile("config.yaml")
        if err != nil {
            log.Fatalf("Error reading embedded config file: %v", err)
        }
    }
    err = yaml.Unmarshal(bytes, &Data)
    if err != nil {
        log.Fatalf("Error parsing config file: %v", err)
    }
}

Enter fullscreen mode Exit fullscreen mode

四. 随机字符串生成 utils/utils.go

完整代码

package utils

import "crypto/rand"

func GenerateRandomString(n int) (string, error) {
    const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    bytes := make([]byte, n)
    if _, err := rand.Read(bytes); err != nil {
        return "", err
    }
    for i, b := range bytes {
        bytes[i] = letters[b%byte(len(letters))]
    }
    return string(bytes), nil
}

Enter fullscreen mode Exit fullscreen mode

这个函数能生成n位的随机字符串

bytes := make([]byte, n):

  • 这行代码使用 make 函数创建一个长度为 nbyte 切片(类似于一个字节数组)。
  • byte 类型代表一个 8 位的无符号整数(即 0 到 255 的值)。
  • 通过 make([]byte, n),你创建了一个初始长度为 n 的切片,用来存储后续生成的随机字节。

if _, err := rand.Read(bytes); err != nil { return "", err }:

  • rand.Read(bytes) 调用了 Go 标准库中的 crypto/rand 包中的 Read 函数,用来生成加密级别的随机数据。
  • 该函数会随机填充 bytes 切片中的每一个元素,使得每个字节都包含一个 0 到 255 之间的随机值。
  • rand.Read 返回两个值:生成的随机字节数(这个你不需要,所以用 _ 忽略掉)和一个可能的错误。
  • 如果生成随机字节的过程出现错误,函数会立即返回空字符串 "" 和错误 err。否则,代码继续执行。

for i, b := range bytes { ... }:

  • 这是一个循环,遍历 bytes 切片中的每一个元素。
  • i 是当前字节在切片中的索引(位置)。
  • b 是当前字节的值,范围是 0255(因为 byte 是 8 位无符号整数)。

bytes[i] = letters[b%byte(len(letters))]:

  • letters 是一个包含字母和数字的字符串,定义为:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"。这是你用来生成随机字符串的字符集。
  • len(letters) 返回 letters 中字符的总数,即 62(26 个小写字母 + 26 个大写字母 + 10 个数字)。
  • b%byte(len(letters)) 是用当前字节的值 b62 取模,结果范围是 061。这将把随机生成的字节值限制在 letters 的索引范围内。
  • letters[b%byte(len(letters))] 通过索引来从 letters 字符串中取出对应的字符。
  • 最后,将该字符赋值给 bytes[i],即用 letters 中的字符替换 bytes 中的原始字节值。

假设 bytes 中的一个值是 150len(letters)62,那么:

  • 150 % 62 = 26
  • 因此,letters[26] 将会返回字符 'A'letters 字符串中大写字母的起始位置)。

image-20240916160022679

五.编写路由 router/router.go

完整代码

package router

import (
    "image_bed/controllers"

    "github.com/gin-gonic/gin"
)

func SetUpImageBedRoute(router *gin.Engine) {
    ImageBedGroup := router.Group("/")
    {
        ImageBedGroup.POST("/upload", controllers.UploadImage)
        ImageBedGroup.GET("/i/:year/:month/:day/:filename", controllers.GetImage)
    }
}

Enter fullscreen mode Exit fullscreen mode
  • 上面的代码中,我们常见了一个路由组,响应/的请求

  • 上传路由:对于来自/upload路径的Post请求,我们要求controllers.UploadImage这个控制器函数进行处理

  • 获取图片路由:对于来自/i/:year/:month/:day/:filename,这里用到了gin的路由动态参数year,month,day,filename会被当做参数传递给GetImage控制器,可以用c.Param(param_name)获取

比如我想获取/i/2024/05/12/cat.png中的图片名,就可以这么获取

func _param(c *gin.Context) {  
    param := c.Param("filename")  
    fmt.Println(param)  
}  
Enter fullscreen mode Exit fullscreen mode

上传路由负责接收客户端上传的图片并处理,返回图片链接

获取图片路由负责响应图片给客户端

Go语言 Web框架GinGo语言 Web框架Gin 返回各种值 返回字符串 返回json 返回map 返回原始json - 掘金 (juejin.cn)

gin动态路由用法可以看上面的链接

六.控制器部分controllers/upload_controller.go

安全校验

我们自己的图床当然不希望别人能随便上传任何图片,因此需要一个安全验证

我们声明一个secretKey来存储来自配置文件中的Token

// 定义一个常量作为秘钥(在实际应用中,请从配置文件或环境变量中获取)
var secretKey string = config.Data.Auth.Token
Enter fullscreen mode Exit fullscreen mode

image-20240916163001560

上传控制器函数

首先我们要校验上传图片的用户是不是我,用token := c.PostForm("token")拿到token,与secretKey进行比对

if token != secretKey {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
        return
    }
Enter fullscreen mode Exit fullscreen mode

如果不相等,说明token是错误的,这个用户没权利上传图片到我们的服务器上,返回给他错误信息

如果相等,我们就要从form表单中提取出图片了

file, err := c.FormFile("file")
if err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get uploaded file"})
    return
}
Enter fullscreen mode Exit fullscreen mode

如果表单里没有file,说明用户没上传图片或者使用的字段是错误的,就告诉他上传失败了

要是找到file了,那我们就要先存储这个图片到服务器上了

// 打开上传的文件
    src, err := file.Open()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open uploaded file"})
        return
    }
    defer src.Close()
// 解码图片
    img, format, err := image.Decode(src)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode image"})
        return
    }

    // 检查文件格式
    if format != "jpeg" && format != "png" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Only JPEG and PNG formats are supported"})
        return
    }
    // 获取当前时间
    now := time.Now()
    year := now.Format("2006")
    month := now.Format("01")
    day := now.Format("02")

    // 构建目录路径
    uploadPath := filepath.Join("./uploads", year, month, day)
    if err := os.MkdirAll(uploadPath, os.ModePerm); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
        return
    }
/*
os.ModePerm 的值为 0777,表示 UNIX 风格的文件权限系统中的权限位,具体分为以下三部分:

用户权限(User permissions):文件所有者的权限(rwx)。
组权限(Group permissions):与文件所有者同组的用户权限(rwx)。
其他用户权限(Other permissions):其他用户的权限(rwx)。
*/

Enter fullscreen mode Exit fullscreen mode

上面的代码分别完成了图片打开,然后解码图片获取图片的信息,查看是不是常见图片格式:jpg或者png

如果不是就告诉用户不支持他上传的这种图片格式

接下来就是创建图片所在的目录了,我们这里采用https://pic.meowrain.cn/year/month/day/xxx.webp这种存储路径,所以需要程序创建对应的文件夹,如果你在2024年9月11日上传了一张图片xxx.jpg,程序会在目录下创建uploads/2024/09/11/路径

uploadPath := filepath.Join("./uploads", year, month, day)
Enter fullscreen mode Exit fullscreen mode

uploadPath这个变量存储的就是上传的路径

接下来我们要把图片由jpg或者png转换为webp,为什么要转?可以看下面的介绍

image-20240916163653454

    // 构建 WebP 文件路径
    randomString, err := utils.GenerateRandomString(6)
    if err != nil {
        log.Println("构建随机字符串失败")
    }
    timestamp := now.UnixNano()
    fileName := randomString + strconv.FormatInt(timestamp, 10)
    webpFileName := fmt.Sprintf("%s.webp", fileName)
    webpFilePath := filepath.Join(uploadPath, webpFileName)

    // 创建 WebP 文件
    out, err := os.Create(webpFilePath)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create WebP file"})
        return
    }
    defer out.Close()

    // 编码并保存图片为 WebP 格式
    err = webp.Encode(out, img, &webp.Options{Lossless: true})
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode image to WebP"})
        return
    }

Enter fullscreen mode Exit fullscreen mode

这部分代码首先调用了随机字符串生成 见上面四,创建了一个随机的6位字符串,为了防止图片名字相撞(虽然几率特别小🤷‍♂️),我们再给图片名加上时间戳

    webpFileName := fmt.Sprintf("%s.webp", fileName)
Enter fullscreen mode Exit fullscreen mode

然后我们就有了文件存储的完整路径了

    webpFilePath := filepath.Join(uploadPath, webpFileName)
Enter fullscreen mode Exit fullscreen mode

然后我们进行转换

    // 创建 WebP 文件
    out, err := os.Create(webpFilePath)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create WebP file"})
        return
    }
    defer out.Close()

    // 编码并保存图片为 WebP 格式
    err = webp.Encode(out, img, &webp.Options{Lossless: true})
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode image to WebP"})
        return
    }

Enter fullscreen mode Exit fullscreen mode

webp这个库怎么用可以看官方文档

接下来就要返回给用户完整的url路径了

// 返回 WebP 文件的 URL
    imageURL := fmt.Sprintf("%s/i/%s/%s/%s/%s", config.Data.Domain, year, month, day, webpFileName)
    c.JSON(http.StatusOK, gin.H{"result": "success", "code": http.StatusOK, "url": imageURL})
Enter fullscreen mode Exit fullscreen mode

完整代码

package controllers

import (
    "fmt"
    "image"
    _ "image/jpeg" // 注册JPEG解码器
    _ "image/png"  // 注册PNG解码器
    "image_bed/config"
    "image_bed/utils"
    "log"
    "net/http"
    "os"
    "path/filepath"
    "strconv"
    "time"

    "github.com/chai2010/webp"
    "github.com/gin-gonic/gin"
)

// 定义一个常量作为秘钥(在实际应用中,请从配置文件或环境变量中获取)
var secretKey string = config.Data.Auth.Token

func UploadImage(c *gin.Context) {
    token := c.PostForm("token")
    if token != secretKey {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
        return
    }
    file, err := c.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get uploaded file"})
        return
    }

    // 打开上传的文件
    src, err := file.Open()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open uploaded file"})
        return
    }
    defer src.Close()

    // 解码图片
    img, format, err := image.Decode(src)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode image"})
        return
    }

    // 检查文件格式
    if format != "jpeg" && format != "png" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Only JPEG and PNG formats are supported"})
        return
    }

    // 获取当前时间
    now := time.Now()
    year := now.Format("2006")
    month := now.Format("01")
    day := now.Format("02")

    // 构建目录路径
    uploadPath := filepath.Join("./uploads", year, month, day)
    if err := os.MkdirAll(uploadPath, os.ModePerm); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
        return
    }

    // 构建 WebP 文件路径
    randomString, err := utils.GenerateRandomString(6)
    if err != nil {
        log.Println("构建随机字符串失败")
    }
    timestamp := now.UnixNano()
    fileName := randomString + strconv.FormatInt(timestamp, 10)
    webpFileName := fmt.Sprintf("%s.webp", fileName)
    webpFilePath := filepath.Join(uploadPath, webpFileName)

    // 创建 WebP 文件
    out, err := os.Create(webpFilePath)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create WebP file"})
        return
    }
    defer out.Close()

    // 编码并保存图片为 WebP 格式
    err = webp.Encode(out, img, &webp.Options{Lossless: true})
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode image to WebP"})
        return
    }

    // 返回 WebP 文件的 URL
    imageURL := fmt.Sprintf("%s/i/%s/%s/%s/%s", config.Data.Domain, year, month, day, webpFileName)
    c.JSON(http.StatusOK, gin.H{"result": "success", "code": http.StatusOK, "url": imageURL})
}


Enter fullscreen mode Exit fullscreen mode

访问图片控制器函数

完整代码

func GetImage(c *gin.Context) {
    year := c.Param("year")
    month := c.Param("month")
    day := c.Param("day")
    filename := c.Param("filename")

    filePath := filepath.Join("./uploads", year, month, day, filename)
    if _, err := os.Stat(filePath); os.IsNotExist(err) {
        c.JSON(http.StatusNotFound, gin.H{"result": "false", "code": http.NotFound, "error": "Image not found"})
        return
    }

    c.File(filePath)
}

Enter fullscreen mode Exit fullscreen mode

就是直接获取来自前端的动态参数,

    year := c.Param("year")
    month := c.Param("month")
    day := c.Param("day")
    filename := c.Param("filename")
Enter fullscreen mode Exit fullscreen mode

然后拼出完整路径

    filePath := filepath.Join("./uploads", year, month, day, filename)
Enter fullscreen mode Exit fullscreen mode

找这个图片存在不存在,不存在返回错误

    if _, err := os.Stat(filePath); os.IsNotExist(err) {
        c.JSON(http.StatusNotFound, gin.H{"result": "false", "code": http.NotFound, "error": "Image not found"})
        return
    }
Enter fullscreen mode Exit fullscreen mode

找到了以后直接返回

    c.File(filePath)
Enter fullscreen mode Exit fullscreen mode

七.main函数部分

package main

import (
    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "image_bed/config"
    . "image_bed/router"
)

func main() {

    r := gin.Default()
    r.Use(cors.New(cors.Config{ // 使用CORS中间件
        AllowAllOrigins:  true,                                                                  // 允许所有来源
        AllowMethods:     []string{"GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS"},          // 允许的HTTP方法
        AllowHeaders:     []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, // 允许的请求头
        ExposeHeaders:    []string{"Content-Length"},                                            // 公开的响应头
        AllowCredentials: true,                                                                  // 允许发送凭据      // 预检请求的有效期
    }))
    SetUpImageBedRoute(r)
    if err := r.Run(":" + config.Data.Port); err != nil {
        panic(err)
    }
}

Enter fullscreen mode Exit fullscreen mode

首先为了防止跨域(可以整合这个程序到自己写的其它服务中,如果你用电脑上的图床软件,比如picgo或者Piclist上传图片,那可以不加cors这个中间件)

掉用route中的SetUpImageBedRoute函数,让其运行再配置文件中的端口上

    r := gin.Default()
    SetUpImageBedRoute(r)
    if err := r.Run(":" + config.Data.Port); err != nil {
        panic(err)
    }
Enter fullscreen mode Exit fullscreen mode

八.测试代码

image-20240916165905556

go run main.go
Enter fullscreen mode Exit fullscreen mode

image-20240916165404762

上传个图片试试

image-20240916170001026

访问看看

image-20240916170022201

image-20240916170144865

九.部署

修改config.yaml为自己的配置

domain: "https://pic.meowrain.cn"
port: 8080
auth:
  token: pic # demo

Enter fullscreen mode Exit fullscreen mode

编写makefile

# 项目名
PROJECT_NAME := img_bed

# 源代码目录
SRC_DIR := .

# 输出目录
OUT_DIR := ./bin

# Go 编译器
GO := go

# 目标平台
PLATFORMS := linux/amd64

# 默认目标
.PHONY: all
all: clean build

# 清理
.PHONY: clean
clean:
    rm -rf $(OUT_DIR)

# 创建输出目录
.PHONY: create-out-dir
create-out-dir:
    mkdir -p $(OUT_DIR)

# 构建
.PHONY: build
build: create-out-dir $(PLATFORMS)

# 针对每个平台编译
$(PLATFORMS):
    GOOS=$(word 1, $(subst /, ,$@)) GOARCH=$(word 2, $(subst /, ,$@)) \
    $(GO) build -o $(OUT_DIR)/$(PROJECT_NAME)-$(word 1, $(subst /, ,$@))-$(word 2, $(subst /, ,$@))$(if $(findstring windows,$@),.exe) $(SRC_DIR)

# 测试
.PHONY: test
test:
    $(GO) test ./...

# 安装依赖
.PHONY: deps
deps:
    $(GO) mod tidy

# 使用方法
.PHONY: help
help:
    @echo "Usage:"
    @echo "  make            - 编译所有平台的可执行文件"
    @echo "  make clean      - 清理输出目录"
    @echo "  make build      - 编译所有平台的可执行文件"
    @echo "  make test       - 运行测试"
    @echo "  make deps       - 安装依赖"
    @echo "  make help       - 显示此帮助信息"
Enter fullscreen mode Exit fullscreen mode

使用make编译

image-20240916170313086

image-20240916170320973

编写nginx反向代理

server {
    listen 80;
    server_name pic.meowrain.cn;

    # 301 Redirect HTTP to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name pic.meowrain.cn;

    # SSL configuration
    ssl_certificate /path/to/your/certificate.crt;
    ssl_certificate_key /path/to/your/private.key;

    # Optionally, you can include additional SSL configurations for better security
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA';
    ssl_prefer_server_ciphers on;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Enter fullscreen mode Exit fullscreen mode

上传二进制文件到服务器,然后使用tmux 把它挂在后台,配置piclist,就可以上传了

Top comments (0)