个人图床-Go实现
https://github.com/meowrain/img-bed-Go
使用到的框架: Gin
使用到的库: github.com/chai2010/webp
一.目录结构
项目如何运行?
什么是反向代理?
二.安装相关库
1
2
3
4
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"
三.配置读取-config/config.go
在config
文件夹中编写config.go
分段分析
我准备读取下面的配置文件
1
2
3
4
Domain : "http://127.0.0.1"
port : 8080
auth :
token : xxx # demo
这里需要分析一下我们的配置文件为什么这么写,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
中
1
2
3
4
5
6
7
type Config struct {
Domain stgring `yaml:"domain"`
Port string `yaml:"port"`
Auth struct {
Token string `yaml:"token"`
} `yaml:"auth"`
}
然后我们需要一个公共变量供外部函数调用
为了能在用户不编写config.yaml
的时候程序也能正常运行,我们需要用到go的embed,方便把配置文件一并打包到二进制文件中
1
2
//go:embed config.yaml
var EmbeddedConfig embed . FS
为了在程序启动时候能够读取配置文件,我们需要编写对应的init函数
这里简单介绍一下init函数
init()
函数是一种在Go语言中用于执行初始化操作的特殊函数。每个包可以包含多个 init()
函数,它们会在包被导入时按照顺序自动执行。init()
函数的调用时机为:
当包被导入时,init()
函数会按照导入的顺序自动执行。
同一个包中的多个 init()
函数按照编写的顺序执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 )
}
}
上面的init函数
,我们使用os.ReadFile
函数读取config.yaml
文件中的内容到bytes数组中,
如果外部配置文件不存在,那么我们就调用打包进二进制文件的默认config.yaml
文件,读取到bytes数组中,然后解析到Data
公共变量中
然后我们使用yaml.Unmarshal
把config.yaml
中的数据解析到Data
公共变量中
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 )
}
}
四. 随机字符串生成 utils/utils.go
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
}
这个函数能生成n位的随机字符串
bytes := make([]byte, n)
:
这行代码使用 make
函数创建一个长度为 n
的 byte
切片(类似于一个字节数组)。
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
是当前字节的值,范围是 0
到 255
(因为 byte
是 8 位无符号整数)。
bytes[i] = letters[b%byte(len(letters))]
:
letters
是一个包含字母和数字的字符串,定义为:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
。这是你用来生成随机字符串的字符集。
len(letters)
返回 letters
中字符的总数,即 62
(26 个小写字母 + 26 个大写字母 + 10 个数字)。
b%byte(len(letters))
是用当前字节的值 b
对 62
取模,结果范围是 0
到 61
。这将把随机生成的字节值限制在 letters
的索引范围内。
letters[b%byte(len(letters))]
通过索引来从 letters
字符串中取出对应的字符。
最后,将该字符赋值给 bytes[i]
,即用 letters
中的字符替换 bytes
中的原始字节值。
假设 bytes
中的一个值是 150
,len(letters)
是 62
,那么:
150 % 62 = 26
因此,letters[26]
将会返回字符 'A'
(letters
字符串中大写字母的起始位置)。
五.编写路由 router/router.go
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 )
}
}
上面的代码中,我们常见了一个路由组,响应/
的请求
上传路由 :对于来自/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
中的图片名,就可以这么获取
1
2
3
4
func _param ( c * gin . Context ) {
param := c . Param ( "filename" )
fmt . Println ( param )
}
上传路由负责接收客户端上传的图片并处理,返回图片链接
获取图片路由负责响应图片给客户端
Go语言 Web框架GinGo语言 Web框架Gin 返回各种值 返回字符串 返回json 返回map 返回原始json - 掘金 (juejin.cn)
gin动态路由用法可以看上面的链接
六.控制器部分controllers/upload_controller.go
安全校验
我们自己的图床当然不希望别人能随便上传任何图片,因此需要一个安全验证
我们声明一个secretKey来存储来自配置文件中的Token
1
2
// 定义一个常量作为秘钥(在实际应用中,请从配置文件或环境变量中获取)
var secretKey string = config . Data . Auth . Token
上传控制器函数
首先我们要校验上传图片的用户是不是我,用 token := c.PostForm("token")
拿到token,与secretKey
进行比对
1
2
3
4
if token != secretKey {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "Invalid API key" })
return
}
如果不相等,说明token是错误的,这个用户没权利上传图片到我们的服务器上,返回给他错误信息
如果相等,我们就要从form表单中提取出图片了
1
2
3
4
5
file , err := c . FormFile ( "file" )
if err != nil {
c . JSON ( http . StatusBadRequest , gin . H { "error" : "Failed to get uploaded file" })
return
}
如果表单里没有file
,说明用户没上传图片或者使用的字段是错误的,就告诉他上传失败了
要是找到file了,那我们就要先存储这个图片到服务器上了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 打开上传的文件
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)。
*/
上面的代码分别完成了图片打开,然后解码图片获取图片的信息,查看是不是常见图片格式:jpg或者png
如果不是就告诉用户不支持他上传的这种图片格式
接下来就是创建图片所在的目录了,我们这里采用https://pic.meowrain.cn/year/month/day/xxx.webp
这种存储路径,所以需要程序创建对应的文件夹,如果你在2024年9月11日上传了一张图片xxx.jpg
,程序会在目录下创建uploads/2024/09/11/
路径
1
uploadPath := filepath . Join ( "./uploads" , year , month , day )
uploadPath这个变量存储的就是上传的路径
接下来我们要把图片由jpg或者png转换为webp,为什么要转?可以看下面的介绍
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 构建 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
}
这部分代码首先调用了[随机字符串生成](#四. 随机字符串生成 utils/utils.go) 见上面四,创建了一个随机的6位字符串,为了防止图片名字相撞(虽然几率特别小🤷♂️),我们再给图片名加上时间戳
1
webpFileName := fmt . Sprintf ( "%s.webp" , fileName )
然后我们就有了文件存储的完整路径了
1
webpFilePath := filepath . Join ( uploadPath , webpFileName )
然后我们进行转换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建 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路径了
1
2
3
// 返回 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 })
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
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 })
}
访问图片控制器函数
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 )
}
就是直接获取来自前端的动态参数,
1
2
3
4
year := c . Param ( "year" )
month := c . Param ( "month" )
day := c . Param ( "day" )
filename := c . Param ( "filename" )
然后拼出完整路径
1
filePath := filepath . Join ( "./uploads" , year , month , day , filename )
找这个图片存在不存在,不存在返回错误
1
2
3
4
if _ , err := os . Stat ( filePath ); os . IsNotExist ( err ) {
c . JSON ( http . StatusNotFound , gin . H { "result" : "false" , "code" : http . NotFound , "error" : "Image not found" })
return
}
找到了以后直接返回
七.main函数部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 )
}
}
首先为了防止跨域 (可以整合这个程序到自己写的其它服务中,如果你用电脑上的图床软件,比如picgo或者Piclist上传图片,那可以不加cors这个中间件)
掉用route中的SetUpImageBedRoute
函数,让其运行再配置文件中的端口上
1
2
3
4
5
r := gin . Default ()
SetUpImageBedRoute ( r )
if err := r . Run ( ":" + config . Data . Port ); err != nil {
panic ( err )
}
八.测试代码
上传个图片试试
访问看看
九.部署
修改config.yaml为自己的配置
1
2
3
4
domain : "https://pic.meowrain.cn"
port : 8080
auth :
token : pic # demo
编写makefile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 项目名
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 - 显示此帮助信息"
使用make编译
编写nginx反向代理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 ;
}
}
上传二进制文件到服务器,然后使用tmux 把它挂在后台,配置piclist,就可以上传了