
2.7 上传图片和文件服务
在处理文章模块时,可以发现blog_article表中的封面图片地址(cover_image_url)是需要传入一个具体的图片地址。而在实际应用中,不同的架构分层有不同的处理方式。例如,第一种是由浏览器端调用前端应用,前端应用(客户端)再调用服务器端进行上传;第二种是浏览器端直接调用服务器端接口上传文件,再调用服务器端的其他业务接口完成业务属性填写。
本节将实现文章的封面图片上传,并用文件服务对外提供静态文件的访问服务,这样在上传图片后,就可以通过约定的地址访问该图片资源了。
2.7.1 新增配置
首先,打开项目下的configs/config.yaml配置文件,新增上传相关的配置代码:

这里一共新增了四项上传文件所必需的配置项,作用如下:
● UploadSavePath:上传文件的最终保存目录。
● UploadServerUrl:上传文件后用于展示的文件服务地址。
● UploadImageMaxSize:上传文件所允许的最大空间大小(MB)。
● UploadImageAllowExts:上传文件所允许的文件后缀。
接下来,在对应的配置结构体上新增上传相关属性。打开项目下的pkg/setting/section.go文件,新增如下代码:

2.7.2 上传文件
下面编写一个上传文件的工具库,它的主要功能是在项目的pkg目录下新建util目录,并创建md5.go文件,写入如下代码:

该方法是对上传后的文件名进行格式化,简单来说,就是对文件名用 MD5 加密后再进行写入,避免直接暴露原始名称。在项目的pkg/upload目录下新建file.go文件,代码如下:

在上述代码中,我们用了两个比较常见的语法。(1)定义FileType为int的类型别名,并把FileType作为类别标识的基础类型。(2)把iota作为它的初始值。
iota又是什么呢?实际上,在Go语言中,iota相当于一个const的常量计数器,也可以理解为枚举值。第一个声明的iota的值为0,在新的一行被使用时,它的值会自动递增。
当然,也可以像前面代码中那样,在初始第一个声明时手动加 1,使其从 1 开始递增。为什么要在FileType类型中使用iota呢?其实是为了在后续有其他需求时,能标准化地进行处理,例如:

在上述代码中,如果还需要支持其他上传文件类型,则十分清晰,不再像以前那样需要手工定义1,2,3,4……
另外,我们还声明了四个文件相关的方法,其作用如下:
● GetFileName:获取文件名称。通过获取文件后缀筛选出原始文件名称,对其进行MD5加密,最后返回经过加密处理后的文件名称。
● GetFileExt:获取文件后缀。通过调用 path.Ext 方法循环查找"."符号,最后通过切片索引返回对应的文件后缀。
● GetSavePath:获取文件保存地址。这里直接返回配置中的文件保存目录即可,以便后续的调整。
在完成文件相关参数的获取方法后,接下来编写检查文件的相关方法。因为需要确保在文件写入时它已经达到了必备条件(否则要给出对应的标准错误提示),所以我们继续在文件内新增如下代码:


● CheckSavePath:检查保存目录是否存在,通过调用 os.Stat 方法获取文件的描述信息FileInfo,并调用os.IsNotExist方法进行判断。其原理是,对os.Stat方法返回的error值与系统中定义的oserror.ErrNotExist值进行比较,以达到校验效果。
● CheckPermission:检查文件权限是否足够。与 CheckSavePath 方法原理一致,即与oserror.ErrPermission值进行比较,进而做出判断。
● CheckContainExt:检查文件后缀是否包含在约定的后缀配置项中。需要注意的是,上传的文件的后缀有可能是大写、小写、大小写混合等,因此我们需要调用strings.ToUpper方法把后缀统一转为大写(固定的格式)后再进行匹配。
● CheckMaxSize:检查文件大小是否超出限制。
在完成检查文件的一些必要操作后,我们就可以对文件进行写入或创建等相关操作了,继续在文件中新增如下代码:


● CreateSavePath:创建保存上传文件的目录。在方法内部调用os.MkdirAll方法,该方法会以传入的 os.FileMode 权限位递归创建所需的所有目录结构。若涉及的目录均已存在,则不进行任何操作,直接返回nil。
● SaveFile:保存上传的文件。该方法通过调用os.Create方法创建目标地址文件,再通过file.Open方法打开源地址的文件,结合io.Copy方法实现两者之间的文件内容拷贝。
2.7.3 新建service方法
我们将上一步编写的上传文件工具库与具体的业务接口结合起来,在 internal/service 目录中新建upload.go文件,写入如下代码:


在UploadFile Service方法中,首先获取文件所需的基本信息,接着对文件进行业务所检查(文件大小是否符合需求、文件后缀是否达到要求),并且判断其是否具备写入条件(目录是否存在、权限是否足够),最后进行真正的写入文件操作。
2.7.4 新增业务错误码
在pkg/errcode下的module_code.go文件中,针对上传模块,新增如下错误代码:

2.7.5 新增路由方法
接下来编写上传文件的路由方法,将整套上传逻辑串联起来。在 internal/routers 下新建upload.go文件,代码如下:


在上述代码中,通过c.Request.FormFile读取入参file字段的上传文件信息,并把入参type字段作为上传文件类型的确立依据(也可以通过解析上传文件后缀来确定文件类型),最后通过入参检查后进行Serivce的调用,完成文件上传和文件保存,返回文件的展示地址。
至此,业务接口的编写就完成了,下一步我们需要添加路由,让外部能够访问该接口。在internal/routers下的router.go文件中,新增上传文件的对应路由,代码如下:

这里新增了POST方法的/upload/file路由,并调用upload.UploadFile方法提供接口的方法响应,至此整体的路由到业务接口的联通就完成了。
2.7.6 验证接口

检查接口返回是否与期望的一致,主体是由UploadServerUrl与加密后的文件名称相结合。
2.7.7 文件服务
当校验接口的返回结果时可以发现,2.8.6 节中的 file_access_url 地址根本就无法访问到对应的文件资源(文件资源确实在storage/uploads目录下),这是为什么呢?
实际上,文件服务只有提供静态资源的访问,才能在外部请求本项目HTTP Server时同时提供静态资源的访问。在gin中实现File Server是非常简单的,只需在NewRouter方法中,新增如下路由即可:

在新增StaticFS路由完毕后,重新启动应用程序,再次访问file_access_url输出的地址,即可看到刚刚上传的静态文件了。
2.7.8 源码分析
为什么设置一个r.StaticFS的路由,就可以拥有一个文件服务,并且能够提供静态资源的访问呢?可以反过来思考,既然能够读取文件的展示,那么在访问$HOST/static 时,应用程序就可以读取blog-service/storage/uploads下的文件。StaticFS方法做了哪些事情呢?方法原型如下:

首先可以看到,在暴露的 URL中程序禁止了“*”和“:”符号的使用;然后通过createStaticHandler创建了静态文件服务,其实最终调用的还是fileServer.ServeHTTP和对应的处理逻辑,代码如下:


在createStaticHandler方法中,可以留意一下http.StripPrefix方法的调用。实际上,它在静态文件服务中十分常见,主要作用是从请求URL的路径中删除给定的前缀,然后返回一个Handler。
另外,在StaticFS方法中可以看到urlPattern:=path.Join(relativePath,"/*filepath")的代码块。/*filepath是什么,又有什么作用呢?我们通过语义可以得知它是路由的处理逻辑,而gin的路由是基于httprouter的,通过查阅文档可以得到如下信息:

简单来说,*filepath会匹配所有的文件路径,但前提是,*filepath标识符必须在Pattern的最后。
2.7.9 小结
本节我们实现了上传图片接口和静态资源文件服务的功能,读者可以从中学习到常见的文件处理操作,以及文件服务访问的实现方式。另外,在实际项目中需要特别注意的是,我们应当将应用服务和文件服务拆分开。因为从安全角度来讲,文件资源不应当与应用资源放在一起,直接采用市面上的OSS也是可以的。