其他分享
首页 > 其他分享> > Gin实践 连载九 优化配置结构及实现图片上传

Gin实践 连载九 优化配置结构及实现图片上传

作者:互联网

优化配置结构及实现图片上传

一天,产品经理突然跟你说文章列表,没有封面图,不够美观,!)&¥!&)#&¥!加一个吧,几分钟的事
你打开你的程序,分析了一波写了个清单:

优化配置结构

一、讲解

在先前章节中,采用了直接读取 KEY 的方式去存储配置项,而本次需求中,需要增加图片的配置项,总体就有些冗余了
我们采用以下解决方法:

映射结构体(示例)

在 go-ini 中可以采用 MapTo 的方式来映射结构体,例如:

type Server struct {
    RunMode string
    HttpPort int
    ReadTimeout time.Duration
    WriteTimeout time.Duration
}
var ServerSetting = &Server{}
func main() {
    Cfg, err := ini.Load("conf/app.ini")
    if err != nil {
        log.Fatalf("Fail to parse 'conf/app.ini': %v", err)
    }
    err = Cfg.Section("server").MapTo(ServerSetting)
    if err != nil {
        log.Fatalf("Cfg.MapTo ServerSetting err: %v", err)
    }
}

在这段代码中,可以注意 ServerSetting 取了地址,为什么 MapTo 必须地址入参呢?

// MapTo maps section to given struct.
func (s *Section) MapTo(v interface{}) error {
    typ := reflect.TypeOf(v)
    val := reflect.ValueOf(v)
    if typ.Kind() == reflect.Ptr {
        typ = typ.Elem()
        val = val.Elem()
    } else {
        return errors.New("cannot map to non-pointer struct")
    }
    return s.mapTo(val, false)
}

在 MapTo 中 typ.Kind() == reflect.Ptr 约束了必须使用指针,否则会返回 cannot map to non-pointer struct 的错误。这个是表面原因
更往内探究,可以认为是 field.Set 的原因,当执行 val := reflect.ValueOf(v) ,函数通过传递 v 拷贝创建了 val,但是 val 的改变并不能更改原始的 v,要想 val 的更改能作用到 v,则必须传递 v 的地址
显然 go-ini 里也是包含修改原始值这一项功能的,你觉得是什么原因呢?

配置统管

在先前的版本中,models 和 file 的配置是在自己的文件中解析的,而其他在 setting.go 中,因此我们需要将其在 setting 中统一接管
你可能会想,直接把两者的配置项复制粘贴到 setting.go 的 init 中,一下子就完事了,搞那么麻烦?
但你在想想,先前的代码中存在多个 init 函数,执行顺序存在问题,无法达到我们的要求,你可以试试
(此处是一个基础知识点)

在go中,当存在多个init函数时:

二、落实

修改配置文件

打开 conf/app.ini 将配置文件修改为大驼峰命名,另外我们增加了 5 个配置项用于上传图片的功能,4 个文件日志方面的配置项

[app]
PageSize = 10
JwtSecret = 233
RuntimeRootPath = runtime/
ImagePrefixUrl = http://127.0.0.1:8000
ImageSavePath = upload/images/
# MB
ImageMaxSize = 5
ImageAllowExts = .jpg,.jpeg,.png
LogSavePath = logs/
LogSaveName = log
LogFileExt = log
TimeFormat = 20060102
[server]
#debug or release
RunMode = debug
HttpPort = 8000
ReadTimeout = 60
WriteTimeout = 60
[database]
Type = mysql
User = root
Password = rootroot
Host = 127.0.0.1:3306
Name = blog
TablePrefix = blog_

优化配置读取及设置初始化顺序

将散落在其他文件里的配置都删掉,统一在 setting 中处理以及修改 init 函数为 Setup 方法
打开 pkg/setting/setting.go 文件,修改如下:

package setting

import (
	"github.com/go-ini/ini"
	"log"
	"time"
)

type App struct {
	PageSize int
	JwtSecret string
	TokenTimeout time.Duration
	RuntimeRootPath string

	ImagePrefixUrl string
	ImageSavePath string
	ImageMaxSize int
	ImageAllowExts []string

	LogSavePath string
	LogSaveName string
	LogFileExt string
	TimeFormat string
}
var AppSetting = &App{}

type Server struct {
	RunMode string
	HttpPort int
	ReadTimeout time.Duration
	WriteTimeout time.Duration
}
var ServerSetting = &Server{}

type Database struct {
	Type string
	User string
	Password string
	Host string
	Name string
}
var DatabaseSetting = &Database{}

func SetUp() {
	cfg, err := ini.Load("conf/app.ini")
	if err != nil {
		log.Fatalf("fail to parse 'conf/app.ini': %v", err)
	}

	if err = cfg.Section("app").MapTo(AppSetting); err != nil {
		log.Fatalf("cfg.Mapto AppSetting err:%v", err)
	}
	AppSetting.ImageMaxSize *= 1024 * 1024

	if err = cfg.Section("server").MapTo(ServerSetting); err != nil {
		log.Fatalf("cfg.Mapto ServerSetting err:%v", err)
	}
	ServerSetting.ReadTimeout *= time.Second
	ServerSetting.WriteTimeout *= time.Second

	if err = cfg.Section("database").MapTo(DatabaseSetting); err != nil {
		log.Fatalf("cfg.Mapto DatabaseSetting err:%v", err)
	}
}

在这里,我们做了如下几件事:

第二步

在这一步我们要设置初始化的流程,打开 main.go 文件,修改内容:

func init() {
	setting.Setup()
	models.Setup()
	logging.Setup()
}

修改完毕后,就成功将多模块的初始化函数放到启动流程中了(先后顺序也可以控制)

验证

在这里为止,针对本需求的配置优化就完毕了,你需要执行 go run main.go 验证一下你的功能是否正常哦
顺带留个基础问题,大家可以思考下

ServerSetting.ReadTimeout = ServerSetting.ReadTimeout * time.Second
ServerSetting.WriteTimeout = ServerSetting.ReadTimeout * time.Second

若将 setting.go 文件中的这两行删除,会出现什么问题,为什么呢?

抽离File

在先前版本中,在 logging/file.go 中使用到了 os 的一些方法,我们通过前期规划发现,这部分在上传图片功能中可以复用

第一步

在 pkg 目录下新建 file/file.go ,写入文件内容如下:

package file

import (
	"errors"
	"io/fs"
	"io/ioutil"
	"mime/multipart"
	"os"
	"path"
)

func GetSize(f multipart.File) (int, error) {
	content, err := ioutil.ReadAll(f)
	return len(content), err
}
func GetExt(fileName string) string {
	return path.Ext(fileName)
}
func CheckExist(src string) bool {
	_, err := os.Stat(src)
	return errors.Is(err, fs.ErrNotExist)
}
func CheckPermission(src string) bool {
	_, err := os.Stat(src)
	return errors.Is(err, fs.ErrPermission)
}
func IsNotExistMkDir(src string) error {
	exist := CheckExist(src)
	if !exist {
		return Mkdir(src)
	}
	return nil
}
func Mkdir(src string) error {
	err := os.MkdirAll(src, os.ModePerm)
	return err
}
func Open(name string, flag int, perm os.FileMode) (*os.File, error) {
	file, err := os.OpenFile(name, flag, perm)
	if err != nil {
		return nil, err
	}
	return file, nil
}

在这里我们一共封装了 7个 方法

第二步

我们在第一步已经将 file 重新封装了一层,在这一步我们将原先 logging 包的方法都修改掉

  1. 打开 pkg/logging/file.go 文件,修改文件内容:
package logging

import (
	"fmt"
	"gin_log/pkg/file"
	"gin_log/pkg/setting"
	"os"
	"time"
)

func getLogFilePath() string {
	return fmt.Sprintf("%s%s", setting.AppSetting.RuntimeRootPath, setting.AppSetting.LogSavePath)
}
func getLogFileName() string {
	return fmt.Sprintf(
		"%s%s.%s",
		setting.AppSetting.LogSaveName,
		time.Now().Format(setting.AppSetting.TimeFormat),
		setting.AppSetting.LogFileExt,
	)
}
func openLogFile(fileName, filePath string) (*os.File, error) {
	dir, err := os.Getwd()
	if err != nil {
		return nil, fmt.Errorf("os.Getwd err: %v", err)
	}
	src := dir + "/" + filePath
	perm := file.CheckPermission(src)
	if perm {
		return nil, fmt.Errorf("file.CheckPermission Permission denied src: %s", src)
	}
	err = file.IsNotExistMkDir(src)
	if err != nil {
		return nil, fmt.Errorf("file.IsNotExistMkDir src=%s, err=%v", src, err)
	}
	f, err := file.Open(src + fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return nil, fmt.Errorf("fail to OpenFile :%v", err)
	}
	return f, err
}

我们将引用都改为了 file/file.go 包里的方法
2. 打开 pkg/logging/log.go 文件,修改文件内容:

func Setup() {
	var err error
	fileName, filePath := getLogFileName(), getLogFilePath()
	F, err = openLogFile(fileName, filePath)
	if err != nil {
		log.Fatalln(err)
	}
	logger = log.New(F, DefaultPrefix, log.LstdFlags)
	go cron.ScheduleTask(updateF)
}
func updateF() {
	var err error
	fileName, filePath := getLogFileName(), getLogFilePath()
	F, err = openLogFile(fileName, filePath)
	if err != nil {
		log.Fatalln(err)
	}
	logger = log.New(F, DefaultPrefix, log.LstdFlags)
}

由于原方法形参改变了,因此 openLogFile 也需要调整

实现上传图片接口

这一小节,我们开始实现上次图片相关的一些方法和功能
首先需要在 blog_article 中增加字段 cover_image_url,格式为 varchar(255) DEFAULT '' COMMENT '封面图片地址'

CoverImageUrl string `json:"cover_image_url" gorm:"type:varchar(255);default:'';comment:'封面图片地址'"`

第零步

一般不会直接将上传的图片名暴露出来,因此我们对图片名进行 MD5 来达到这个效果
在 util 目录下新建 md5.go,写入文件内容:

package util

import (
	"crypto/md5"
	"encoding/hex"
)

func EncodeMD5(value string) string {
	m := md5.New()
	m.Write([]byte(value))
	return hex.EncodeToString(m.Sum(nil))
}

第一步

在先前我们已经把底层方法给封装好了,实质这一步为封装 image 的处理逻辑
在 pkg 目录下新建 upload/image.go 文件,写入文件内容:

package upload

import (
	"fmt"
	"gin_log/pkg/file"
	"gin_log/pkg/logging"
	"gin_log/pkg/setting"
	"gin_log/pkg/util"
	"log"
	"mime/multipart"
	"os"
	"path"
	"strings"
)

func GetImageFullUrl(name string) string {
	return setting.AppSetting.ImagePrefixUrl + "/" + GetImagePath() + name
}
func GetImageName(name string) string {
	ext := path.Ext(name)
	fileName := strings.TrimSuffix(name, ext)
	fileName = util.EncodeMD5(fileName)
	return fileName + ext
}
func GetImagePath() string {
	return setting.AppSetting.ImageSavePath
}
func GetImageFullPath(name string) string {
	return setting.AppSetting.RuntimeRootPath + GetImagePath()
}
func CheckImageExt(fileName string) bool {
	ext := file.GetExt(fileName)
	for _, allowExt := range setting.AppSetting.ImageAllowExts {
		if strings.ToUpper(allowExt) == strings.ToUpper(ext) {
			return true
		}
	}
	return false
}
func CheckImageSize(f multipart.File) bool {
	size, err := file.GetSize(f)
	if err != nil {
		log.Println(err)
		logging.Warn(err)
		return false
	}
	return size <= setting.AppSetting.ImageMaxSize
}
func CheckImage(src string) error {
	dir, err := os.Getwd()
	if err != nil {
		return fmt.Errorf("os.Getwd err: %v", err)
	}

	err = file.IsNotExistMkDir(dir + "/" + src)
	if err != nil {
		return fmt.Errorf("file.IsNotExistMkDir err: %v", err)
	}

	perm := file.CheckPermission(src)
	if perm {
		return fmt.Errorf("file.CheckPermission permission denied src: %s", src)
	}

	return nil
}

这里我们实现了7个方法:

第二步

这一步将编写上传图片的业务逻辑,在 routers/api 目录下 新建 upload.go 文件,写入文件内容:

package v1

import (
	"gin_log/pkg/e"
	"gin_log/pkg/logging"
	"gin_log/pkg/upload"
	"github.com/gin-gonic/gin"
	"net/http"
)

func UploadImage(c *gin.Context) {
	code := e.SUCCESS
	data := map[string]any{}

	f, fHeader, err := c.Request.FormFile("image")
	if err != nil {
		logging.Warn(err)
		code = e.ERROR
		c.JSON(http.StatusOK, gin.H{
			"code": code,
			"msg": e.GetMsg(code),
			"data": data,
		})
		return
	}

	if fHeader == nil {
		code = e.INVALID_PARAMS
	}else {
		imageName := upload.GetImageName(fHeader.Filename)
		fullPath := upload.GetImageFullPath()
		savePath := upload.GetImagePath()

		src := fullPath + imageName
		if !upload.CheckImageExt(imageName) || !upload.CheckImageSize(f) {
			code = e.ERROR_UPLOAD_CHECK_IMAGE_FORMAT
		}else{
			err = upload.CheckImage(fullPath)
			if err != nil {
				logging.Warn(err)
				code = e.ERROR_UPLOAD_CHECK_IMAGE_FAIL
			}else if err = c.SaveUploadedFile(fHeader, src); err != nil {
				logging.Warn(err)
				code = e.ERROR_UPLOAD_SAVE_IMAGE_FAIL
			}else{
				data["image_url"] = upload.GetImageFullUrl(imageName)
				data["image_save_url"] = savePath + imageName
			}
		}
	}
	
	c.JSON(http.StatusOK, gin.H{
		"code": code,
		"msg": e.GetMsg(code),
		"data": data,
	})
}

所涉及的错误码(需在 pkg/e/code.go、msg.go 添加):

// 保存图片失败
ERROR_UPLOAD_SAVE_IMAGE_FAIL = 30001
// 检查图片失败
ERROR_UPLOAD_CHECK_IMAGE_FAIL = 30002
// 校验图片错误,图片格式或大小有问题
ERROR_UPLOAD_CHECK_IMAGE_FORMAT = 30003

在这一大段的业务逻辑中,我们做了如下事情:

c.Request.FormFile:获取上传的图片(返回提供的表单键的第一个文件)
CheckImageExt、CheckImageSize检查图片大小,检查图片后缀
CheckImage:检查上传图片所需(权限、文件夹)
SaveUploadedFile:保存图片
总的来说,就是 入参 -> 检查 -》 保存 的应用流程

第三步

打开 routers/router.go 文件,增加路由 r.POST("/upload", api.UploadImage) ,如:

	{
		apiv1.POST("/upload", v1.UploadImage)
	}

验证

最后我们请求一下上传图片的接口,测试所编写的功能
http://127.0.0.1:8000/api/v1/upload
返回值:

{
	"code": 200,
	"data": {
		"image_save_url": "upload/images/0e76c8b85d0295088d773aaaceabddd3.png",
		"image_url": "http://127.0.0.1:8000/runtime/upload/images/0e76c8b85d0295088d773aaaceabddd3.png"
	},
	"msg": "ok"
}

检查目录下是否含文件(注意权限问题)
在这里我们一共返回了 2 个参数,一个是完整的访问 URL,另一个为保存路径

实现http.FileServer

在完成了上一小节后,我们还需要让前端能够访问到图片,一般是如下:

r.StaticFS

打开 routers/router.go 文件,增加路由 r.StaticFS("/upload/images", http.Dir(upload.GetImageFullPath())),如:
r.StaticFS("/runtime/upload/images", http.Dir(upload.GetImageFullPath()))

它做了什么

当访问 $HOST/upload/images 时,将会读取到 gin_blog/runtime/upload/images 下的文件
而这行代码又做了什么事呢,我们来看看方法原型

// StaticFS works just like `Static()` but a custom `http.FileSystem` can be used instead.
// Gin by default user: gin.Dir()
func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) IRoutes {
    if strings.Contains(relativePath, ":") || strings.Contains(relativePath, "*") {
        panic("URL parameters can not be used when serving a static folder")
    }
    handler := group.createStaticHandler(relativePath, fs)
    urlPattern := path.Join(relativePath, "/*filepath")
    // Register GET and HEAD handlers
    group.GET(urlPattern, handler)
    group.HEAD(urlPattern, handler)
    return group.returnObj()
}

首先在暴露的 URL 中禁止了 * 和 : 符号的使用,通过 createStaticHandler 创建了静态文件服务,实质最终调用的还是 fileServer.ServeHTTP 和一些处理逻辑了

func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
    absolutePath := group.calculateAbsolutePath(relativePath)
    fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))
    _, nolisting := fs.(*onlyfilesFS)
    return func(c *Context) {
        if nolisting {
            c.Writer.WriteHeader(404)
        }
        fileServer.ServeHTTP(c.Writer, c.Request)
    }
}

http.StripPrefix

我们可以留意下 fileServer := http.StripPrefix(absolutePath, http.FileServer(fs)) 这段语句,在静态文件服务中很常见,它有什么作用呢?
http.StripPrefix 主要作用是从请求 URL 的路径中删除给定的前缀,最终返回一个 Handler
通常 http.FileServer 要与 http.StripPrefix 相结合使用,否则当你运行:

http.Handle("/upload/images", http.FileServer(http.Dir("upload/images")))

会无法正确的访问到文件目录,因为 /upload/images 也包含在了 URL 路径中,必须使用:

http.Handle("/upload/images", http.StripPrefix("upload/images", http.FileServer(http.Dir("upload/images"))))

/*filepath

到下面可以看到 urlPattern := path.Join(relativePath, "/filepath"),/filepath 你是谁,你在这里有什么用,你是 Gin 的产物吗?
通过语义可得知是路由的处理逻辑,而 Gin 的路由是基于 httprouter 的,通过查阅文档可得到以下信息

Pattern: /src/*filepath
 /src/                     match
 /src/somefile.go          match
 /src/subdir/somefile.go   match

*filepath 将匹配所有文件路径,并且 *filepath 必须在 Pattern 的最后

验证

重新执行 go run main.go ,去访问刚刚在 upload 接口得到的图片地址,检查 http.FileSystem 是否正常
http://127.0.0.1:8000/upload/images/8e2a74ef5878d0b64eca379cb22c795d.png

修改文章接口

接下来,需要你修改 routers/api/v1/article.go 的 AddArticle、EditArticle 两个接口

总结

在这章节中,我们简单的分析了下需求,对应用做出了一个小规划并实施
完成了清单中的功能点和优化,在实际项目中也是常见的场景,希望你能够细细品尝并针对一些点进行深入学习

标签:http,连载,err,upload,return,go,Gin,上传,string
来源: https://www.cnblogs.com/mayanan/p/16689789.html