Golang Gin實踐 連載十二 優化配置結構及實現圖片上傳

原文地址:優化配置結構及實現圖片上傳
項目地址:https://github.com/EDDYCJY/go...html

若是對你有所幫助,歡迎點個 Star 👍前端

前言

一天,產品經理忽然跟你說文章列表,沒有封面圖,不夠美觀,!)&¥!&)#&¥!加一個吧,幾分鐘的事mysql

你打開你的程序,分析了一波寫了個清單:git

  • 優化配置結構(由於配置項愈來愈多)
  • 抽離 原 logging 的 File 便於公用(logging、upload 各保有一份並不合適)
  • 實現上傳圖片接口(需限制文件格式、大小)
  • 修改文章接口(需支持封面地址參數)
  • 增長 blog_article (文章)的數據庫字段
  • 實現 http.FileServer

嗯,你發現要較優的話,須要調整部分的應用程序結構,由於功能愈來愈多,本來的設計也要跟上節奏github

也就是在適當的時候,及時優化golang

優化配置結構

1、講解

在先前章節中,採用了直接讀取 KEY 的方式去存儲配置項,而本次需求中,須要增長圖片的配置項,整體就有些冗餘了sql

咱們採用如下解決方法:數據庫

  • 映射結構體:使用 MapTo 來設置配置參數
  • 配置統管:全部的配置項統管到 setting 中

映射結構體(示例)

在 go-ini 中能夠採用 MapTo 的方式來映射結構體,例如:segmentfault

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 必須地址入參呢?api

// 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 函數時,執行順序爲:

  • 相同包下的 init 函數:按照源文件編譯順序決定執行順序(默認按文件名排序)
  • 不一樣包下的 init 函數:按照包導入的依賴關係決定前後順序

因此要避免多 init 的狀況,儘可能由程序把控初始化的前後順序

2、落實

修改配置文件

打開 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 (
    "log"
    "time"

    "github.com/go-ini/ini"
)

type App struct {
    JwtSecret string
    PageSize int
    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
    TablePrefix 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)
    }

    err = Cfg.Section("app").MapTo(AppSetting)
    if err != nil {
        log.Fatalf("Cfg.MapTo AppSetting err: %v", err)
    }

    AppSetting.ImageMaxSize = AppSetting.ImageMaxSize * 1024 * 1024

    err = Cfg.Section("server").MapTo(ServerSetting)
    if err != nil {
        log.Fatalf("Cfg.MapTo ServerSetting err: %v", err)
    }

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

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

在這裏,咱們作了以下幾件事:

  • 編寫與配置項保持一致的結構體(App、Server、Database)
  • 使用 MapTo 將配置項映射到結構體上
  • 對一些需特殊設置的配置項進行再賦值

須要你去作的事:

這幾項比較基礎,並無貼出來,我但願你能夠本身動手,有問題的話可右拐 項目地址

第二步

在這一步咱們要設置初始化的流程,打開 main.go 文件,修改內容:

func main() {
    setting.Setup()
    models.Setup()
    logging.Setup()

    endless.DefaultReadTimeOut = setting.ServerSetting.ReadTimeout
    endless.DefaultWriteTimeOut = setting.ServerSetting.WriteTimeout
    endless.DefaultMaxHeaderBytes = 1 << 20
    endPoint := fmt.Sprintf(":%d", setting.ServerSetting.HttpPort)

    server := endless.NewServer(endPoint, routers.InitRouter())
    server.BeforeBegin = func(add string) {
        log.Printf("Actual pid is %d", syscall.Getpid())
    }

    err := server.ListenAndServe()
    if err != nil {
        log.Printf("Server err: %v", err)
    }
}

修改完畢後,就成功將多模塊的初始化函數放到啓動流程中了(前後順序也能夠控制)

驗證

在這裏爲止,針對本需求的配置優化就完畢了,你須要執行 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 (
    "os"
    "path"
    "mime/multipart"
    "io/ioutil"
)

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 os.IsNotExist(err)
}

func CheckPermission(src string) bool {
    _, err := os.Stat(src)

    return os.IsPermission(err)
}

func IsNotExistMkDir(src string) error {
    if exist := CheckExist(src); exist == false {
        if err := MkDir(src); err != nil {
            return err
        }
    }

    return nil
}

func MkDir(src string) error {
    err := os.MkdirAll(src, os.ModePerm)
    if err != nil {
        return err
    }

    return nil
}

func Open(name string, flag int, perm os.FileMode) (*os.File, error) {
    f, err := os.OpenFile(name, flag, perm)
    if err != nil {
        return nil, err
    }

    return f, nil
}

在這裏咱們一共封裝了 7個 方法

  • GetSize:獲取文件大小
  • GetExt:獲取文件後綴
  • CheckExist:檢查文件是否存在
  • CheckPermission:檢查文件權限
  • IsNotExistMkDir:若是不存在則新建文件夾
  • MkDir:新建文件夾
  • Open:打開文件

在這裏咱們用到了 mime/multipart 包,它主要實現了 MIME 的 multipart 解析,主要適用於 HTTP 和常見瀏覽器生成的 multipart 主體

multipart 又是什麼,rfc2388 的 multipart/form-data 瞭解一下

第二步

咱們在第一步已經將 file 從新封裝了一層,在這一步咱們將原先 logging 包的方法都修改掉

一、打開 pkg/logging/file.go 文件,修改文件內容:

package logging

import (
    "fmt"
    "os"
    "time"

    "github.com/EDDYCJY/go-gin-example/pkg/setting"
    "github.com/EDDYCJY/go-gin-example/pkg/file"
)

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 == true {
        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, nil
}

咱們將引用都改成了 file/file.go 包裏的方法

二、打開 pkg/logging/log.go 文件,修改文件內容:

package logging

...

func Setup() {
    var err error
    filePath := getLogFilePath()
    fileName := getLogFileName()
    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 '封面圖片地址'

第零步

通常不會直接將上傳的圖片名暴露出來,所以咱們對圖片名進行 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 (
    "os"
    "path"
    "log"
    "fmt"
    "strings"
    "mime/multipart"

    "github.com/EDDYCJY/go-gin-example/pkg/file"
    "github.com/EDDYCJY/go-gin-example/pkg/setting"
    "github.com/EDDYCJY/go-gin-example/pkg/logging"
    "github.com/EDDYCJY/go-gin-example/pkg/util"
)

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() 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 == true {
        return fmt.Errorf("file.CheckPermission Permission denied src: %s", src)
    }

    return nil
}

在這裏咱們實現了 7 個方法,以下:

  • GetImageFullUrl:獲取圖片完整訪問URL
  • GetImageName:獲取圖片名稱
  • GetImagePath:獲取圖片路徑
  • GetImageFullPath:獲取圖片完整路徑
  • CheckImageExt:檢查圖片後綴
  • CheckImageSize:檢查圖片大小
  • CheckImage:檢查圖片

這裏基本是對底層代碼的二次封裝,爲了更靈活的處理一些圖片特有的邏輯,而且方便修改,不直接對外暴露下層

第二步

這一步將編寫上傳圖片的業務邏輯,在 routers/api 目錄下 新建 upload.go 文件,寫入文件內容:

package api

import (
    "net/http"

    "github.com/gin-gonic/gin"

    "github.com/EDDYCJY/go-gin-example/pkg/e"
    "github.com/EDDYCJY/go-gin-example/pkg/logging"
    "github.com/EDDYCJY/go-gin-example/pkg/upload"
)

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

    file, image, 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,
        })
    }

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

        src := fullPath + imageName
        if ! upload.CheckImageExt(imageName) || ! upload.CheckImageSize(file) {
            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(image, 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) ,如:

func InitRouter() *gin.Engine {
    r := gin.New()
    ...
    r.GET("/auth", api.GetAuth)
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    r.POST("/upload", api.UploadImage)

    apiv1 := r.Group("/api/v1")
    apiv1.Use(jwt.JWT())
    {
        ...
    }

    return r
}

驗證

最後咱們請求一下上傳圖片的接口,測試所編寫的功能

image

檢查目錄下是否含文件(注意權限問題)

$ pwd
$GOPATH/src/github.com/EDDYCJY/go-gin-example/runtime/upload/images

$ ll
... 96a3be3cf272e017046d1b2674a52bd3.jpg
... c39fa784216313cf2faa7c98739fc367.jpeg

在這裏咱們一共返回了 2 個參數,一個是完整的訪問 URL,另外一個爲保存路徑

實現 http.FileServer

在完成了上一小節後,咱們還須要讓前端可以訪問到圖片,通常是以下:

  • CDN
  • http.FileSystem

在公司的話,CDN 或自建分佈式文件系統居多,也不須要過多關注。而在實踐裏的話確定是本地搭建了,Go 自己對此就有很好的支持,而 Gin 更是再封裝了一層,只須要在路由增長一行代碼便可

r.StaticFS

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

func InitRouter() *gin.Engine {
    ...
    r.StaticFS("/upload/images", http.Dir(upload.GetImageFullPath()))

    r.GET("/auth", api.GetAuth)
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    r.POST("/upload", api.UploadImage)
    ...
}

它作了什麼

當訪問 $HOST/upload/images 時,將會讀取到 $GOPATH/src/github.com/EDDYCJY/go-gin-example/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 是否正常

image

修改文章接口

接下來,須要你修改 routers/api/v1/article.go 的 AddArticle、EditArticle 兩個接口

  • 新增、更新文章接口:支持入參 cover_image_url
  • 新增、更新文章接口:增長對 cover_image_url 的非空、最長長度校驗

這塊前面文章講過,若是有問題能夠參考項目的代碼👌

總結

在這章節中,咱們簡單的分析了下需求,對應用作出了一個小規劃並實施

完成了清單中的功能點和優化,在實際項目中也是常見的場景,但願你可以細細品嚐並針對一些點進行深刻學習

參考

本系列示例代碼

本系列目錄

相關文章
相關標籤/搜索