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

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

最佳化配置結構及實現圖片上傳

項目地址:https://github.com/EDDYCJY/go...

如果對你有所協助,歡迎點個 Star

前言

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

你開啟你的程式,分析了一波寫了個清單:

  • 最佳化配置結構(因為配置項越來越多)
  • 抽離 原 logging 的 File 便於公用(logging、upload 各保有一份並不合適)
  • 實現上傳圖片介面(需限制檔案格式、大小)
  • 修改文章介面(需支援封面地址參數)
  • 增加 blog_article (文章)的資料庫欄位
  • 實現 http.FileServer

嗯,你發現要較優的話,需要調整部分的應用程式結構,因為功能越來越多,原本的設計也要跟上節奏

也就是在適當的時候,及時最佳化

最佳化配置結構

一、講解

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

我們採用以下解決方案:

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

映射結構體(樣本)

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

  • 相同包下的 init 函數:按照源檔案編譯順序決定執行順序(預設按檔案名稱排序)
  • 不同包下的 init 函數:按照包匯入的依賴關係決定先後順序

所以要避免多 init 的情況,盡量由程式把控初始化的先後順序

二、落實

修改設定檔

開啟 conf/app.ini 將設定檔修改為大駝峰命名,另外我們增加了 5 個配置項用於上傳圖片的功能,4 個檔案日誌方面的配置項

[app]PageSize = 10JwtSecret = 233RuntimeRootPath = runtime/ImagePrefixUrl = http://127.0.0.1:8000ImageSavePath = upload/images/# MBImageMaxSize = 5ImageAllowExts = .jpg,.jpeg,.pngLogSavePath = logs/LogSaveName = logLogFileExt = logTimeFormat = 20060102[server]#debug or releaseRunMode = debugHttpPort = 8000ReadTimeout = 60WriteTimeout = 60[database]Type = mysqlUser = rootPassword = rootrootHost = 127.0.0.1:3306Name = blogTablePrefix = blog_

最佳化配置讀取及設定初始化順序

第一步

將散落在其他檔案裡的配置都刪掉,統一在 setting 中處理以及修改 init 函數為 Setup 方法

開啟 pkg/setting/setting.go 檔案,修改如下:

package settingimport (    "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 將配置項映射到結構體上
  • 對一些需特殊設定的配置項進行再賦值
  • 將 models.go、setting.go、pkg/logging/log.go 的 init 函數修改為 Setup 方法
  • 將 models/models.go 獨立讀取的 DB 配置項刪除,改為統一讀取 setting
  • 將 pkg/logging/file 獨立的 LOG 配置項刪除,改為統一讀取 setting

後面幾項比較基礎,並沒有貼出來,我相信你能夠解決,有問題的話可右拐 項目地址

第二步

在這一步我們要設定初始化的流程,開啟 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.SecondServerSetting.WriteTimeout = ServerSetting.ReadTimeout * time.Second

若將 setting.go 檔案中的這兩行刪除,會出現什麼問題,為什麼呢?

抽離 File

在先前版本中,在 logging/file.go 中使用到了 os 的一些方法,我們通過前期規劃發現,這部分在上傳圖片功能中可以複用

第一步

在 pkg 目錄下建立 file/file.go ,寫入檔案內容如下:

package fileimport (    "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 包的方法都修改掉

1、開啟 pkg/logging/file.go 檔案,修改檔案內容:

package loggingimport (    "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 包裡的方法

2、開啟 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 utilimport (    "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 uploadimport (    "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 apiimport (    "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}

驗證

最後我們請求一下上傳圖片的介面,測試所編寫的功能

檢查目錄下是否含檔案(注意許可權問題)

$ 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 或自建Distributed File System居多,也不需要過多關注。而在實踐裡的話肯定是本地搭建了,Go 本身對此就有很好的支援,而 Gin 更是再封裝了一層,只需要在路由增加一行代碼即可

開啟 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 和一些處理邏輯了

在下面可以看到 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 是否正常

修改文章介面

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

  • 新增、更新文章介面:支援入參 cover_image_url
  • 新增、更新文章介面:增加對 cover_image_url 的非空、最長長度校正

這塊前面文章講過,如果有問題可以參考項目的代碼

總結

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

完成了清單中的功能點和最佳化,在實際項目中也是常見的情境,希望你能夠細細品嘗並針對一些點進行深入學習

參考

本系列範例程式碼

  • go-gin-example

本系列目錄

  • Gin實踐 連載一 Golang介紹與環境安裝
  • Gin實踐 連載二 搭建Blog API's(一)
  • Gin實踐 連載三 搭建Blog API's(二)
  • Gin實踐 連載四 搭建Blog API's(三)
  • Gin實踐 連載五 搭建Blog API's(四)
  • Gin實踐 連載六 搭建Blog API's(五)
  • Gin實踐 連載七 Golang優雅重啟HTTP服務
  • Gin實踐 連載八 為它加上Swagger
  • Gin實踐 連載九 將Golang應用部署到Docker
  • Gin實踐 連載十 定製 GORM Callbacks
  • Gin實踐 連載十一 Cron定時任務
  • Gin實踐 番外 Golang交叉編譯
相關關鍵詞:
相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.