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

知识点

    重构、调整结构

本文目标

这个应用程序跑了那么久了,越来越大,越来越壮,仿佛我们的产品一样,现在它需要进行小范围重构了,以便于后续的使用,这非常重要。

前言

一天,产品经理突然跟你说文章列表,没有封面图,不够美观,!)&¥!&)#&¥!加一个吧,几分钟的事
你打开你的程序,分析了一波写了个清单:
    优化配置结构(因为配置项越来越多)
    抽离 原 logging 的 File 便于公用(logging、upload 各保有一份并不合适)
    实现上传图片接口(需限制文件格式、大小)
    修改文章接口(需支持封面地址参数)
    增加 blog_article (文章)的数据库字段
    实现 http.FileServer
嗯,你发现要较优的话,需要调整部分的应用程序结构,因为功能越来越多,原本的设计也要跟上节奏
也就是在适当的时候,及时优化

优化配置结构

一、讲解

在先前章节中,采用了直接读取 KEY 的方式去存储配置项,而本次需求中,需要增加图片的配置项,总体就有些冗余了
我们采用以下解决方法:
    映射结构体:使用 MapTo 来设置配置参数
    配置统管:所有的配置项统管到 setting 中

映射结构体(示例)

在 go-ini 中可以采用 MapTo 的方式来映射结构体,例如:
1
type Server struct {
2
RunMode string
3
HttpPort int
4
ReadTimeout time.Duration
5
WriteTimeout time.Duration
6
}
7
8
var ServerSetting = &Server{}
9
10
func main() {
11
Cfg, err := ini.Load("conf/app.ini")
12
if err != nil {
13
log.Fatalf("Fail to parse 'conf/app.ini': %v", err)
14
}
15
16
err = Cfg.Section("server").MapTo(ServerSetting)
17
if err != nil {
18
log.Fatalf("Cfg.MapTo ServerSetting err: %v", err)
19
}
20
}
Copied!
在这段代码中,可以注意 ServerSetting 取了地址,为什么 MapTo 必须地址入参呢?
1
// MapTo maps section to given struct.
2
func (s *Section) MapTo(v interface{}) error {
3
typ := reflect.TypeOf(v)
4
val := reflect.ValueOf(v)
5
if typ.Kind() == reflect.Ptr {
6
typ = typ.Elem()
7
val = val.Elem()
8
} else {
9
return errors.New("cannot map to non-pointer struct")
10
}
11
12
return s.mapTo(val, false)
13
}
Copied!
在 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
5
RuntimeRootPath = runtime/
6
7
ImagePrefixUrl = http://127.0.0.1:8000
8
ImageSavePath = upload/images/
9
# MB
10
ImageMaxSize = 5
11
ImageAllowExts = .jpg,.jpeg,.png
12
13
LogSavePath = logs/
14
LogSaveName = log
15
LogFileExt = log
16
TimeFormat = 20060102
17
18
[server]
19
#debug or release
20
RunMode = debug
21
HttpPort = 8000
22
ReadTimeout = 60
23
WriteTimeout = 60
24
25
[database]
26
Type = mysql
27
User = root
28
Password = rootroot
29
Host = 127.0.0.1:3306
30
Name = blog
31
TablePrefix = blog_
Copied!

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

第一步
将散落在其他文件里的配置都删掉,统一在 setting 中处理以及修改 init 函数为 Setup 方法
打开 pkg/setting/setting.go 文件,修改如下:
1
package setting
2
3
import (
4
"log"
5
"time"
6
7
"github.com/go-ini/ini"
8
)
9
10
type App struct {
11
JwtSecret string
12
PageSize int
13
RuntimeRootPath string
14
15
ImagePrefixUrl string
16
ImageSavePath string
17
ImageMaxSize int
18
ImageAllowExts []string
19
20
LogSavePath string
21
LogSaveName string
22
LogFileExt string
23
TimeFormat string
24
}
25
26
var AppSetting = &App{}
27
28
type Server struct {
29
RunMode string
30
HttpPort int
31
ReadTimeout time.Duration
32
WriteTimeout time.Duration
33
}
34
35
var ServerSetting = &Server{}
36
37
type Database struct {
38
Type string
39
User string
40
Password string
41
Host string
42
Name string
43
TablePrefix string
44
}
45
46
var DatabaseSetting = &Database{}
47
48
func Setup() {
49
Cfg, err := ini.Load("conf/app.ini")
50
if err != nil {
51
log.Fatalf("Fail to parse 'conf/app.ini': %v", err)
52
}
53
54
err = Cfg.Section("app").MapTo(AppSetting)
55
if err != nil {
56
log.Fatalf("Cfg.MapTo AppSetting err: %v", err)
57
}
58
59
AppSetting.ImageMaxSize = AppSetting.ImageMaxSize * 1024 * 1024
60
61
err = Cfg.Section("server").MapTo(ServerSetting)
62
if err != nil {
63
log.Fatalf("Cfg.MapTo ServerSetting err: %v", err)
64
}
65
66
ServerSetting.ReadTimeout = ServerSetting.ReadTimeout * time.Second
67
ServerSetting.WriteTimeout = ServerSetting.ReadTimeout * time.Second
68
69
err = Cfg.Section("database").MapTo(DatabaseSetting)
70
if err != nil {
71
log.Fatalf("Cfg.MapTo DatabaseSetting err: %v", err)
72
}
73
}
Copied!
在这里,我们做了如下几件事:
    编写与配置项保持一致的结构体(App、Server、Database)
    使用 MapTo 将配置项映射到结构体上
    对一些需特殊设置的配置项进行再赋值
需要你去做的事:
这几项比较基础,并没有贴出来,我希望你可以自己动手,有问题的话可右拐 项目地址
第二步
在这一步我们要设置初始化的流程,打开 main.go 文件,修改内容:
1
func main() {
2
setting.Setup()
3
models.Setup()
4
logging.Setup()
5
6
endless.DefaultReadTimeOut = setting.ServerSetting.ReadTimeout
7
endless.DefaultWriteTimeOut = setting.ServerSetting.WriteTimeout
8
endless.DefaultMaxHeaderBytes = 1 << 20
9
endPoint := fmt.Sprintf(":%d", setting.ServerSetting.HttpPort)
10
11
server := endless.NewServer(endPoint, routers.InitRouter())
12
server.BeforeBegin = func(add string) {
13
log.Printf("Actual pid is %d", syscall.Getpid())
14
}
15
16
err := server.ListenAndServe()
17
if err != nil {
18
log.Printf("Server err: %v", err)
19
}
20
}
Copied!
修改完毕后,就成功将多模块的初始化函数放到启动流程中了(先后顺序也可以控制)
验证
在这里为止,针对本需求的配置优化就完毕了,你需要执行 go run main.go 验证一下你的功能是否正常哦
顺带留个基础问题,大家可以思考下
1
ServerSetting.ReadTimeout = ServerSetting.ReadTimeout * time.Second
2
ServerSetting.WriteTimeout = ServerSetting.ReadTimeout * time.Second
Copied!
若将 setting.go 文件中的这两行删除,会出现什么问题,为什么呢?

抽离 File

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

第一步

在 pkg 目录下新建 file/file.go ,写入文件内容如下:
1
package file
2
3
import (
4
"os"
5
"path"
6
"mime/multipart"
7
"io/ioutil"
8
)
9
10
func GetSize(f multipart.File) (int, error) {
11
content, err := ioutil.ReadAll(f)
12
13
return len(content), err
14
}
15
16
func GetExt(fileName string) string {
17
return path.Ext(fileName)
18
}
19
20
func CheckExist(src string) bool {
21
_, err := os.Stat(src)
22
23
return os.IsNotExist(err)
24
}
25
26
func CheckPermission(src string) bool {
27
_, err := os.Stat(src)
28
29
return os.IsPermission(err)
30
}
31
32
func IsNotExistMkDir(src string) error {
33
if notExist := CheckNotExist(src); notExist == true {
34
if err := MkDir(src); err != nil {
35
return err
36
}
37
}
38
39
return nil
40
}
41
42
func MkDir(src string) error {
43
err := os.MkdirAll(src, os.ModePerm)
44
if err != nil {
45
return err
46
}
47
48
return nil
49
}
50
51
func Open(name string, flag int, perm os.FileMode) (*os.File, error) {
52
f, err := os.OpenFile(name, flag, perm)
53
if err != nil {
54
return nil, err
55
}
56
57
return f, nil
58
}
Copied!
在这里我们一共封装了 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 文件,修改文件内容:
1
package logging
2
3
import (
4
"fmt"
5
"os"
6
"time"
7
8
"github.com/EDDYCJY/go-gin-example/pkg/setting"
9
"github.com/EDDYCJY/go-gin-example/pkg/file"
10
)
11
12
func getLogFilePath() string {
13
return fmt.Sprintf("%s%s", setting.AppSetting.RuntimeRootPath, setting.AppSetting.LogSavePath)
14
}
15
16
func getLogFileName() string {
17
return fmt.Sprintf("%s%s.%s",
18
setting.AppSetting.LogSaveName,
19
time.Now().Format(setting.AppSetting.TimeFormat),
20
setting.AppSetting.LogFileExt,
21
)
22
}
23
24
func openLogFile(fileName, filePath string) (*os.File, error) {
25
dir, err := os.Getwd()
26
if err != nil {
27
return nil, fmt.Errorf("os.Getwd err: %v", err)
28
}
29
30
src := dir + "/" + filePath
31
perm := file.CheckPermission(src)
32
if perm == true {
33
return nil, fmt.Errorf("file.CheckPermission Permission denied src: %s", src)
34
}
35
36
err = file.IsNotExistMkDir(src)
37
if err != nil {
38
return nil, fmt.Errorf("file.IsNotExistMkDir src: %s, err: %v", src, err)
39
}
40
41
f, err := file.Open(src + fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
42
if err != nil {
43
return nil, fmt.Errorf("Fail to OpenFile :%v", err)
44
}
45
46
return f, nil
47
}
Copied!
我们将引用都改为了 file/file.go 包里的方法
2、打开 pkg/logging/log.go 文件,修改文件内容:
1
package logging
2
3
...
4
5
func Setup() {
6
var err error
7
filePath := getLogFilePath()
8
fileName := getLogFileName()
9
F, err = openLogFile(fileName, filePath)
10
if err != nil {
11
log.Fatalln(err)
12
}
13
14
logger = log.New(F, DefaultPrefix, log.LstdFlags)
15
}
16
17
...
Copied!
由于原方法形参改变了,因此 openLogFile 也需要调整

实现上传图片接口

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

第零步

一般不会直接将上传的图片名暴露出来,因此我们对图片名进行 MD5 来达到这个效果
在 util 目录下新建 md5.go,写入文件内容:
1
package util
2
3
import (
4
"crypto/md5"
5
"encoding/hex"
6
)
7
8
func EncodeMD5(value string) string {
9
m := md5.New()
10
m.Write([]byte(value))
11
12
return hex.EncodeToString(m.Sum(nil))
13
}
Copied!

第一步

在先前我们已经把底层方法给封装好了,实质这一步为封装 image 的处理逻辑
在 pkg 目录下新建 upload/image.go 文件,写入文件内容:
1
package upload
2
3
import (
4
"os"
5
"path"
6
"log"
7
"fmt"
8
"strings"
9
"mime/multipart"
10
11
"github.com/EDDYCJY/go-gin-example/pkg/file"
12
"github.com/EDDYCJY/go-gin-example/pkg/setting"
13
"github.com/EDDYCJY/go-gin-example/pkg/logging"
14
"github.com/EDDYCJY/go-gin-example/pkg/util"
15
)
16
17
func GetImageFullUrl(name string) string {
18
return setting.AppSetting.ImagePrefixUrl + "/" + GetImagePath() + name
19
}
20
21
func GetImageName(name string) string {
22
ext := path.Ext(name)
23
fileName := strings.TrimSuffix(name, ext)
24
fileName = util.EncodeMD5(fileName)
25
26
return fileName + ext
27
}
28
29
func GetImagePath() string {
30
return setting.AppSetting.ImageSavePath
31
}
32
33
func GetImageFullPath() string {
34
return setting.AppSetting.RuntimeRootPath + GetImagePath()
35
}
36
37
func CheckImageExt(fileName string) bool {
38
ext := file.GetExt(fileName)
39
for _, allowExt := range setting.AppSetting.ImageAllowExts {
40
if strings.ToUpper(allowExt) == strings.ToUpper(ext) {
41
return true
42
}
43
}
44
45
return false
46
}
47
48
func CheckImageSize(f multipart.File) bool {
49
size, err := file.GetSize(f)
50
if err != nil {
51
log.Println(err)
52
logging.Warn(err)
53
return false
54
}
55
56
return size <= setting.AppSetting.ImageMaxSize
57
}
58
59
func CheckImage(src string) error {
60
dir, err := os.Getwd()
61
if err != nil {
62
return fmt.Errorf("os.Getwd err: %v", err)
63
}
64
65
err = file.IsNotExistMkDir(dir + "/" + src)
66
if err != nil {
67
return fmt.Errorf("file.IsNotExistMkDir err: %v", err)
68
}
69
70
perm := file.CheckPermission(src)
71
if perm == true {
72
return fmt.Errorf("file.CheckPermission Permission denied src: %s", src)
73
}
74
75
return nil
76
}
Copied!
在这里我们实现了 7 个方法,如下:
    GetImageFullUrl:获取图片完整访问URL
    GetImageName:获取图片名称
    GetImagePath:获取图片路径
    GetImageFullPath:获取图片完整路径
    CheckImageExt:检查图片后缀
    CheckImageSize:检查图片大小
    CheckImage:检查图片
这里基本是对底层代码的二次封装,为了更灵活的处理一些图片特有的逻辑,并且方便修改,不直接对外暴露下层

第二步

这一步将编写上传图片的业务逻辑,在 routers/api 目录下 新建 upload.go 文件,写入文件内容:
1
package api
2
3
import (
4
"net/http"
5
6
"github.com/gin-gonic/gin"
7
8
"github.com/EDDYCJY/go-gin-example/pkg/e"
9
"github.com/EDDYCJY/go-gin-example/pkg/logging"
10
"github.com/EDDYCJY/go-gin-example/pkg/upload"
11
)
12
13
func UploadImage(c *gin.Context) {
14
code := e.SUCCESS
15
data := make(map[string]string)
16
17
file, image, err := c.Request.FormFile("image")
18
if err != nil {
19
logging.Warn(err)
20
code = e.ERROR
21
c.JSON(http.StatusOK, gin.H{
22
"code": code,
23
"msg": e.GetMsg(code),
24
"data": data,
25
})
26
}
27
28
if image == nil {
29
code = e.INVALID_PARAMS
30
} else {
31
imageName := upload.GetImageName(image.Filename)
32
fullPath := upload.GetImageFullPath()
33
savePath := upload.GetImagePath()
34
35
src := fullPath + imageName
36
if ! upload.CheckImageExt(imageName) || ! upload.CheckImageSize(file) {
37
code = e.ERROR_UPLOAD_CHECK_IMAGE_FORMAT
38
} else {
39
err := upload.CheckImage(fullPath)
40
if err != nil {
41
logging.Warn(err)
42
code = e.ERROR_UPLOAD_CHECK_IMAGE_FAIL
43
} else if err := c.SaveUploadedFile(image, src); err != nil {
44
logging.Warn(err)
45
code = e.ERROR_UPLOAD_SAVE_IMAGE_FAIL
46
} else {
47
data["image_url"] = upload.GetImageFullUrl(imageName)
48
data["image_save_url"] = savePath + imageName
49
}
50
}
51
}
52
53
c.JSON(http.StatusOK, gin.H{
54
"code": code,
55
"msg": e.GetMsg(code),
56
"data": data,
57
})
58
}
Copied!
所涉及的错误码(需在 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
Copied!
在这一大段的业务逻辑中,我们做了如下事情:
    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
8
apiv1 := r.Group("/api/v1")
9
apiv1.Use(jwt.JWT())
10
{
11
...
12
}
13
14
return r
15
}
Copied!

验证

最后我们请求一下上传图片的接口,测试所编写的功能
image
检查目录下是否含文件(注意权限问题)
1
$ pwd
2
$GOPATH/src/github.com/EDDYCJY/go-gin-example/runtime/upload/images
3
4
$ ll
5
... 96a3be3cf272e017046d1b2674a52bd3.jpg
6
... c39fa784216313cf2faa7c98739fc367.jpeg
Copied!
在这里我们一共返回了 2 个参数,一个是完整的访问 URL,另一个为保存路径

实现 http.FileServer

在完成了上一小节后,我们还需要让前端能够访问到图片,一般是如下:
    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
5
r.GET("/auth", api.GetAuth)
6
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
7
r.POST("/upload", api.UploadImage)
8
...
9
}
Copied!

它做了什么

当访问 $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
10
// Register GET and HEAD handlers
11
group.GET(urlPattern, handler)
12
group.HEAD(urlPattern, handler)
13
return group.returnObj()
14
}
Copied!
首先在暴露的 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
}
Copied!

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")))
Copied!
会无法正确的访问到文件目录,因为 /upload/images 也包含在了 URL 路径中,必须使用:
1
http.Handle("/upload/images", http.StripPrefix("upload/images", http.FileServer(http.Dir("upload/images"))))
Copied!

/*filepath

到下面可以看到 urlPattern := path.Join(relativePath, "/*filepath")/*filepath 你是谁,你在这里有什么用,你是 Gin 的产物吗?
通过语义可得知是路由的处理逻辑,而 Gin 的路由是基于 httprouter 的,通过查阅文档可得到以下信息
1
Pattern: /src/*filepath
2
3
/src/ match
4
/src/somefile.go match
5
/src/subdir/somefile.go match
Copied!
*filepath 将匹配所有文件路径,并且 *filepath 必须在 Pattern 的最后

验证

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

修改文章接口

接下来,需要你修改 routers/api/v1/article.go 的 AddArticle、EditArticle 两个接口
    新增、更新文章接口:支持入参 cover_image_url
    新增、更新文章接口:增加对 cover_image_url 的非空、最长长度校验
这块前面文章讲过,如果有问题可以参考项目的代码👌

总结

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

参考

本系列示例代码

关于

修改记录

    第一版:2018年02月16日发布文章
    第二版:2019年10月02日修改文章

如果有任何疑问或错误,欢迎在 issues 进行提问或给予修正意见,如果喜欢或对你有所帮助,欢迎 Star,对作者是一种鼓励和推进。

我的公众号

image
Last modified 2yr ago