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

    如果对你有所帮助,欢迎点个 Star ?

    一天,产品经理突然跟你说文章列表,没有封面图,不够美观,!)&¥!&)#&¥!加一个吧,几分钟的事

    你打开你的程序,分析了一波写了个清单:

    • 优化配置结构(因为配置项越来越多)
    • 抽离 原 logging 的 File 便于公用(logging、upload 各保有一份并不合适)
    • 实现上传图片接口(需限制文件格式、大小)
    • 修改文章接口(需支持封面地址参数)
    • 增加 blog_article (文章)的数据库字段
    • 实现 http.FileServer

    嗯,你发现要较优的话,需要调整部分的应用程序结构,因为功能越来越多,原本的设计也要跟上节奏

    也就是在适当的时候,及时优化

    优化配置结构

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

    我们采用以下解决方法:

    • 映射结构体:使用 MapTo 来设置配置参数
    • 配置统管:所有的配置项统管到 setting 中

    映射结构体(示例)

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

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

    1. func (s *Section) MapTo(v interface{}) error {
    2. typ := reflect.TypeOf(v)
    3. val := reflect.ValueOf(v)
    4. if typ.Kind() == reflect.Ptr {
    5. typ = typ.Elem()
    6. val = val.Elem()
    7. } else {
    8. return errors.New("cannot map to non-pointer struct")
    9. }
    10. return s.mapTo(val, false)
    11. }

    在 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 个文件日志方面的配置项

    1. [app]
    2. PageSize = 10
    3. JwtSecret = 233
    4. RuntimeRootPath = runtime/
    5. ImagePrefixUrl = http://127.0.0.1:8000
    6. ImageSavePath = upload/images/
    7. # MB
    8. ImageMaxSize = 5
    9. ImageAllowExts = .jpg,.jpeg,.png
    10. LogSavePath = logs/
    11. LogSaveName = log
    12. LogFileExt = log
    13. TimeFormat = 20060102
    14. [server]
    15. #debug or release
    16. RunMode = debug
    17. HttpPort = 8000
    18. ReadTimeout = 60
    19. WriteTimeout = 60
    20. [database]
    21. Type = mysql
    22. User = root
    23. Password = rootroot
    24. Host = 127.0.0.1:3306
    25. Name = blog
    26. TablePrefix = blog_

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

    第一步

    将散落在其他文件里的配置都删掉,统一在 setting 中处理以及修改 init 函数为 Setup 方法

    打开 pkg/setting/setting.go 文件,修改如下:

    1. package setting
    2. import (
    3. "log"
    4. "time"
    5. "github.com/go-ini/ini"
    6. )
    7. type App struct {
    8. JwtSecret string
    9. PageSize int
    10. RuntimeRootPath string
    11. ImagePrefixUrl string
    12. ImageSavePath string
    13. ImageMaxSize int
    14. ImageAllowExts []string
    15. LogSavePath string
    16. LogSaveName string
    17. LogFileExt string
    18. TimeFormat string
    19. }
    20. var AppSetting = &App{}
    21. type Server struct {
    22. RunMode string
    23. HttpPort int
    24. ReadTimeout time.Duration
    25. WriteTimeout time.Duration
    26. }
    27. var ServerSetting = &Server{}
    28. type Database struct {
    29. Type string
    30. User string
    31. Password string
    32. Host string
    33. Name string
    34. TablePrefix string
    35. }
    36. var DatabaseSetting = &Database{}
    37. func Setup() {
    38. Cfg, err := ini.Load("conf/app.ini")
    39. if err != nil {
    40. log.Fatalf("Fail to parse 'conf/app.ini': %v", err)
    41. }
    42. err = Cfg.Section("app").MapTo(AppSetting)
    43. if err != nil {
    44. log.Fatalf("Cfg.MapTo AppSetting err: %v", err)
    45. }
    46. AppSetting.ImageMaxSize = AppSetting.ImageMaxSize * 1024 * 1024
    47. err = Cfg.Section("server").MapTo(ServerSetting)
    48. if err != nil {
    49. log.Fatalf("Cfg.MapTo ServerSetting err: %v", err)
    50. }
    51. ServerSetting.ReadTimeout = ServerSetting.ReadTimeout * time.Second
    52. ServerSetting.WriteTimeout = ServerSetting.ReadTimeout * time.Second
    53. err = Cfg.Section("database").MapTo(DatabaseSetting)
    54. if err != nil {
    55. log.Fatalf("Cfg.MapTo DatabaseSetting err: %v", err)
    56. }
    57. }

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

    • 编写与配置项保持一致的结构体(App、Server、Database)
    • 使用 MapTo 将配置项映射到结构体上
    • 对一些需特殊设置的配置项进行再赋值

    需要你去做的事:

    • 将 、setting.go、 的 init 函数修改为 Setup 方法
    • models/models.go 独立读取的 DB 配置项删除,改为统一读取 setting
    • 将 独立的 LOG 配置项删除,改为统一读取 setting

    这几项比较基础,并没有贴出来,我希望你可以自己动手,有问题的话可右拐 项目地址

    第二步
    1. setting.Setup()
    2. models.Setup()
    3. logging.Setup()
    4. endless.DefaultReadTimeOut = setting.ServerSetting.ReadTimeout
    5. endless.DefaultWriteTimeOut = setting.ServerSetting.WriteTimeout
    6. endless.DefaultMaxHeaderBytes = 1 << 20
    7. endPoint := fmt.Sprintf(":%d", setting.ServerSetting.HttpPort)
    8. server := endless.NewServer(endPoint, routers.InitRouter())
    9. server.BeforeBegin = func(add string) {
    10. log.Printf("Actual pid is %d", syscall.Getpid())
    11. }
    12. err := server.ListenAndServe()
    13. if err != nil {
    14. log.Printf("Server err: %v", err)
    15. }
    16. }

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

    验证

    在这里为止,针对本需求的配置优化就完毕了,你需要执行 go run main.go 验证一下你的功能是否正常哦

    顺带留个基础问题,大家可以思考下

    1. ServerSetting.ReadTimeout = ServerSetting.ReadTimeout * time.Second

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

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

    第一步

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

    1. package file
    2. import (
    3. "os"
    4. "path"
    5. "mime/multipart"
    6. "io/ioutil"
    7. )
    8. func GetSize(f multipart.File) (int, error) {
    9. content, err := ioutil.ReadAll(f)
    10. return len(content), err
    11. }
    12. func GetExt(fileName string) string {
    13. return path.Ext(fileName)
    14. }
    15. func CheckExist(src string) bool {
    16. _, err := os.Stat(src)
    17. return os.IsNotExist(err)
    18. }
    19. func CheckPermission(src string) bool {
    20. _, err := os.Stat(src)
    21. return os.IsPermission(err)
    22. }
    23. func IsNotExistMkDir(src string) error {
    24. if exist := CheckExist(src); exist == false {
    25. if err := MkDir(src); err != nil {
    26. return err
    27. }
    28. }
    29. return nil
    30. }
    31. func MkDir(src string) error {
    32. err := os.MkdirAll(src, os.ModePerm)
    33. if err != nil {
    34. return err
    35. }
    36. return nil
    37. }
    38. func Open(name string, flag int, perm os.FileMode) (*os.File, error) {
    39. f, err := os.OpenFile(name, flag, perm)
    40. if err != nil {
    41. return nil, err
    42. }
    43. return f, nil
    44. }

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

    • GetSize:获取文件大小
    • GetExt:获取文件后缀
    • CheckExist:检查文件是否存在
    • CheckPermission:检查文件权限
    • IsNotExistMkDir:如果不存在则新建文件夹
    • MkDir:新建文件夹
    • Open:打开文件

    在这里我们用到了 mime/multipart 包,它主要实现了 MIME 的 multipart 解析,主要适用于 HTTP 和常见浏览器生成的 multipart 主体

    multipart 又是什么, 的 multipart/form-data 了解一下

    第二步

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

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

    我们将引用都改为了 file/file.go 包里的方法

    2、打开 pkg/logging/log.go 文件,修改文件内容:

    1. package logging
    2. ...
    3. func Setup() {
    4. var err error
    5. filePath := getLogFilePath()
    6. fileName := getLogFileName()
    7. F, err = openLogFile(fileName, filePath)
    8. if err != nil {
    9. log.Fatalln(err)
    10. }
    11. logger = log.New(F, DefaultPrefix, log.LstdFlags)
    12. }
    13. ...

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

    实现上传图片接口

    这一小节,我们开始实现上次图片相关的一些方法和功能

    首先需要在 blog_article 中增加字段 cover_image_url,格式为 varchar(255) DEFAULT '' COMMENT '封面图片地址'

    一般不会直接将上传的图片名暴露出来,因此我们对图片名进行 MD5 来达到这个效果

    在 util 目录下新建 md5.go,写入文件内容:

    1. package util
    2. import (
    3. "crypto/md5"
    4. "encoding/hex"
    5. )
    6. func EncodeMD5(value string) string {
    7. m := md5.New()
    8. m.Write([]byte(value))
    9. return hex.EncodeToString(m.Sum(nil))
    10. }

    第一步

    在先前我们已经把底层方法给封装好了,实质这一步为封装 image 的处理逻辑

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

    1. package upload
    2. import (
    3. "os"
    4. "path"
    5. "log"
    6. "fmt"
    7. "strings"
    8. "mime/multipart"
    9. "github.com/EDDYCJY/go-gin-example/pkg/file"
    10. "github.com/EDDYCJY/go-gin-example/pkg/setting"
    11. "github.com/EDDYCJY/go-gin-example/pkg/logging"
    12. "github.com/EDDYCJY/go-gin-example/pkg/util"
    13. )
    14. func GetImageFullUrl(name string) string {
    15. return setting.AppSetting.ImagePrefixUrl + "/" + GetImagePath() + name
    16. }
    17. func GetImageName(name string) string {
    18. ext := path.Ext(name)
    19. fileName := strings.TrimSuffix(name, ext)
    20. fileName = util.EncodeMD5(fileName)
    21. return fileName + ext
    22. }
    23. func GetImagePath() string {
    24. return setting.AppSetting.ImageSavePath
    25. }
    26. func GetImageFullPath() string {
    27. return setting.AppSetting.RuntimeRootPath + GetImagePath()
    28. }
    29. func CheckImageExt(fileName string) bool {
    30. ext := file.GetExt(fileName)
    31. for _, allowExt := range setting.AppSetting.ImageAllowExts {
    32. if strings.ToUpper(allowExt) == strings.ToUpper(ext) {
    33. return true
    34. }
    35. }
    36. return false
    37. }
    38. size, err := file.GetSize(f)
    39. if err != nil {
    40. log.Println(err)
    41. logging.Warn(err)
    42. return false
    43. }
    44. return size <= setting.AppSetting.ImageMaxSize
    45. }
    46. dir, err := os.Getwd()
    47. if err != nil {
    48. return fmt.Errorf("os.Getwd err: %v", err)
    49. }
    50. err = file.IsNotExistMkDir(dir + "/" + src)
    51. if err != nil {
    52. return fmt.Errorf("file.IsNotExistMkDir err: %v", err)
    53. }
    54. perm := file.CheckPermission(src)
    55. if perm == true {
    56. return fmt.Errorf("file.CheckPermission Permission denied src: %s", src)
    57. }
    58. return nil
    59. }

    在这里我们实现了 7 个方法,如下:

    • GetImageFullUrl:获取图片完整访问URL
    • GetImageName:获取图片名称
    • GetImagePath:获取图片路径
    • GetImageFullPath:获取图片完整路径
    • CheckImageExt:检查图片后缀
    • CheckImageSize:检查图片大小
    • CheckImage:检查图片

    这里基本是对底层代码的二次封装,为了更灵活的处理一些图片特有的逻辑,并且方便修改,不直接对外暴露下层

    第二步

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

    1. package api
    2. import (
    3. "net/http"
    4. "github.com/gin-gonic/gin"
    5. "github.com/EDDYCJY/go-gin-example/pkg/e"
    6. "github.com/EDDYCJY/go-gin-example/pkg/logging"
    7. "github.com/EDDYCJY/go-gin-example/pkg/upload"
    8. )
    9. func UploadImage(c *gin.Context) {
    10. code := e.SUCCESS
    11. data := make(map[string]string)
    12. file, image, err := c.Request.FormFile("image")
    13. if err != nil {
    14. logging.Warn(err)
    15. code = e.ERROR
    16. c.JSON(http.StatusOK, gin.H{
    17. "code": code,
    18. "msg": e.GetMsg(code),
    19. "data": data,
    20. })
    21. }
    22. if image == nil {
    23. code = e.INVALID_PARAMS
    24. } else {
    25. imageName := upload.GetImageName(image.Filename)
    26. fullPath := upload.GetImageFullPath()
    27. savePath := upload.GetImagePath()
    28. src := fullPath + imageName
    29. if ! upload.CheckImageExt(imageName) || ! upload.CheckImageSize(file) {
    30. code = e.ERROR_UPLOAD_CHECK_IMAGE_FORMAT
    31. } else {
    32. err := upload.CheckImage(fullPath)
    33. if err != nil {
    34. logging.Warn(err)
    35. code = e.ERROR_UPLOAD_CHECK_IMAGE_FAIL
    36. } else if err := c.SaveUploadedFile(image, src); err != nil {
    37. logging.Warn(err)
    38. code = e.ERROR_UPLOAD_SAVE_IMAGE_FAIL
    39. } else {
    40. data["image_url"] = upload.GetImageFullUrl(imageName)
    41. data["image_save_url"] = savePath + imageName
    42. }
    43. }
    44. }
    45. c.JSON(http.StatusOK, gin.H{
    46. "code": code,
    47. "msg": e.GetMsg(code),
    48. "data": data,
    49. })
    50. }

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

    1. // 保存图片失败
    2. ERROR_UPLOAD_SAVE_IMAGE_FAIL = 30001
    3. // 检查图片失败
    4. ERROR_UPLOAD_CHECK_IMAGE_FAIL = 30002
    5. // 校验图片错误,图片格式或大小有问题
    6. ERROR_UPLOAD_CHECK_IMAGE_FORMAT = 30003
    • c.Request.FormFile:获取上传的图片(返回提供的表单键的第一个文件)
    • CheckImageExt、CheckImageSize检查图片大小,检查图片后缀
    • CheckImage:检查上传图片所需(权限、文件夹)
    • SaveUploadedFile:保存图片

    总的来说,就是 入参 -> 检查 -》 保存 的应用流程

    第三步

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

    1. func InitRouter() *gin.Engine {
    2. r := gin.New()
    3. ...
    4. r.GET("/auth", api.GetAuth)
    5. r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    6. r.POST("/upload", api.UploadImage)
    7. apiv1 := r.Group("/api/v1")
    8. apiv1.Use(jwt.JWT())
    9. {
    10. ...
    11. }
    12. return r
    13. }

    最后我们请求一下上传图片的接口,测试所编写的功能

    检查目录下是否含文件(注意权限问题)

    在这里我们一共返回了 2 个参数,一个是完整的访问 URL,另一个为保存路径

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

    • CDN
    • http.FileSystem

    在公司的话,CDN 或自建分布式文件系统居多,也不需要过多关注。而在实践里的话肯定是本地搭建了,Go 本身对此就有很好的支持,而 Gin 更是再封装了一层,只需要在路由增加一行代码即可

    r.StaticFS

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

    1. func InitRouter() *gin.Engine {
    2. ...
    3. r.StaticFS("/upload/images", http.Dir(upload.GetImageFullPath()))
    4. r.GET("/auth", api.GetAuth)
    5. r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    6. r.POST("/upload", api.UploadImage)
    7. ...
    8. }

    它做了什么

    当访问 $HOST/upload/images 时,将会读取到 $GOPATH/src/github.com/EDDYCJY/go-gin-example/runtime/upload/images 下的文件

    而这行代码又做了什么事呢,我们来看看方法原型

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

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

    1. func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
    2. absolutePath := group.calculateAbsolutePath(relativePath)
    3. fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))
    4. _, nolisting := fs.(*onlyfilesFS)
    5. return func(c *Context) {
    6. if nolisting {
    7. c.Writer.WriteHeader(404)
    8. }
    9. fileServer.ServeHTTP(c.Writer, c.Request)
    10. }
    11. }

    http.StripPrefix

    我们可以留意下 fileServer := http.StripPrefix(absolutePath, http.FileServer(fs)) 这段语句,在静态文件服务中很常见,它有什么作用呢?

    http.StripPrefix 主要作用是从请求 URL 的路径中删除给定的前缀,最终返回一个 Handler

    通常 http.FileServer 要与 http.StripPrefix 相结合使用,否则当你运行:

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

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

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

    /*filepath

    到下面可以看到 urlPattern := path.Join(relativePath, "/*filepath")/*filepath 你是谁,你在这里有什么用,你是 Gin 的产物吗?

    通过语义可得知是路由的处理逻辑,而 Gin 的路由是基于 httprouter 的,通过查阅文档可得到以下信息

    1. Pattern: /src/*filepath
    2. /src/ match
    3. /src/somefile.go match
    4. /src/subdir/somefile.go match

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

    验证

    重新执行 ,去访问刚刚在 upload 接口得到的图片地址,检查 http.FileSystem 是否正常

    image

    修改文章接口

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

    • 新增、更新文章接口:支持入参 cover_image_url
    • 新增、更新文章接口:增加对 cover_image_url 的非空、最长长度校验

    这块前面文章讲过,如果有问题可以参考项目的代码?

    在这章节中,我们简单的分析了下需求,对应用做出了一个小规划并实施

    参考