开箱即用的GO后台管理系统 Kratos Admin - 如何上传文件

开箱即用的GO后台管理系统 Kratos Admin - 如何上传文件

在一个CMS和Admin系统里面,文件上传是一个极其重要的功能之一。

在Kraots-Admin里面,我们把所有的文件都落地到MinIO。MinIO是一个非常优秀的分布式文件管理系统。

通常,后端可用的有两种上传方式:

  1. 通过Kratos的服务向MinIO申请预签名URL,然后通过预签名URL向MinIO上传文件。
  2. 直接向Kratos的服务上传文件,然后,微服务再将文件落地到MinIO。

方式一,这是最优的解决方案,因为文件不会经过微服务,直接上传到MinIO,减轻了微服务的压力。并且,MinIO支持分布式部署,可以很好的扩展。

方式二,是最简单的解决方案,但是不推荐,因为文件需要微服务经手,这显然增加了微服务的压力。

向MinIO预签名URL上传文件

该方法的原理就是,微服务向MinIO发出请求,让MinIO生成一个预签名(Presigned)的链接,你可以理解成为一个有时效性的上传链接,在一定的时间内,你有权上传一个文件。在FTP时代里,我们需要向客户端/前端暴露用户名和密码,这是极其不安全的。而预签名机制则是一个安全的机制。

让我们来一步步的实现该功能。

MinIO提供了两种预签名的上传方式:

  1. PresignedPutObject 提供一个临时的HTTP PUT 操作预签名上传链接以供上传
  2. PresignedPostPolicy 提供一个临时的HTTP POST 操作预签名上传链接以供上传

我们接着就将这两个方法封装起来:

go 复制代码
package minio

import (
	"context"
	"log"
	"net/url"
	"time"

	"github.com/minio/minio-go/v7"
	"github.com/minio/minio-go/v7/pkg/credentials"
)

const (
	defaultExpiryTime = time.Second * 24 * 60 * 60 // 1 day

	endpoint        string = "localhost:9000"
	accessKeyID     string = "root"
	secretAccessKey string = "123456789"
	useSSL          bool   = false
)

type Client struct {
	cli *minio.Client
}

func NewMinioClient() *Client {
	cli, err := minio.New(endpoint, &minio.Options{
		Creds:  credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
		Secure: useSSL,
	})
	if err != nil {
		log.Fatalln(err)
	}

	return &Client{
		cli: cli,
	}
}

func (c *Client) PostPresignedUrl(ctx context.Context, bucketName, objectName string) (string, map[string]string, error) {
	expiry := defaultExpiryTime

	policy := minio.NewPostPolicy()
	_ = policy.SetBucket(bucketName)
	_ = policy.SetKey(objectName)
	_ = policy.SetExpires(time.Now().UTC().Add(expiry))

	presignedURL, formData, err := c.cli.PresignedPostPolicy(ctx, policy)
	if err != nil {
		log.Fatalln(err)
		return "", map[string]string{}, err
	}

	return presignedURL.String(), formData, nil
}

func (c *Client) PutPresignedUrl(ctx context.Context, bucketName, objectName string) (string, error) {
	expiry := defaultExpiryTime

	presignedURL, err := c.cli.PresignedPutObject(ctx, bucketName, objectName, expiry)
	if err != nil {
		log.Fatalln(err)
		return "", err
	}

	return presignedURL.String(), nil
}

现在,我们需要定义Protobuf的API:

protobuf 复制代码
syntax = "proto3";

package file.service.v1;

import "gnostic/openapi/v3/annotations.proto";
import "google/api/annotations.proto";

// 文件服务
service FileService {
  // 获取对象存储(OSS)上传链接
  rpc OssUploadUrl (OssUploadUrlRequest) returns (OssUploadUrlResponse) {
    option (google.api.http) = {
      post: "/admin/v1/file:upload-url"
      body: "*"
    };
  }
}

// 前端上传文件所用的HTTP方法
enum UploadMethod {
  Put = 0;
  Post = 1;
}

// 获取对象存储上传链接 - 请求
message OssUploadUrlRequest {
  UploadMethod method = 1 [
    json_name = "method",
    (gnostic.openapi.v3.property) = { description: "上传文件所用的HTTP方法,支持POST和PUT" }
  ];  // 上传文件所用的HTTP方法

  optional string content_type = 2 [
    json_name = "contentType",
    (gnostic.openapi.v3.property) = { description: "文件的MIME类型" }
  ];  // 文件的MIME类型

  optional string bucket_name = 3 [
    json_name = "bucketName",
    (gnostic.openapi.v3.property) = { description: "文件桶名称,如果不填写,将会根据文件名或者MIME类型进行自动解析" }
  ]; // 文件桶名称,如果不填写,将会根据文件名或者MIME类型进行自动解析。

  optional string file_path = 4 [
    json_name = "filePath",
    (gnostic.openapi.v3.property) = { description: "远端的文件路径,可以不填写" }
  ]; // 远端的文件路径,可以不填写。

  optional string file_name = 5 [
    json_name = "fileName",
    (gnostic.openapi.v3.property) = { description: "文件名,如果不填写,则会生成UUID,有同名文件也会改为UUID" }
  ]; // 文件名,如果不填写,则会生成UUID,有同名文件也会改为UUID。
}

// 获取对象存储上传链接 - 回应
message OssUploadUrlResponse {
  string upload_url = 1 [
    json_name = "uploadUrl",
    (gnostic.openapi.v3.property) = { description: "文件的上传链接,默认1个小时的过期时间" }
  ]; // 文件的上传链接,默认1个小时的过期时间。

  string download_url = 2 [
    json_name = "downloadUrl",
    (gnostic.openapi.v3.property) = { description: "文件的下载链接" }
  ]; // 文件的下载链接

  optional string bucket_name = 3 [
    json_name = "bucketName",
    (gnostic.openapi.v3.property) = { description: "文件桶名称" }
  ]; // 文件桶名称

  string object_name = 4 [
    json_name = "objectName",
    (gnostic.openapi.v3.property) = { description: "文件名" }
  ];  // 文件名

  map<string, string> form_data = 5 [
    json_name = "formData",
    (gnostic.openapi.v3.property) = { description: "表单数据,使用POST方法时填写" }
  ];
}

写好了API之后,接着来实现服务:

go 复制代码
package service

import (
	"context"

	"github.com/go-kratos/kratos/v2/log"
	"github.com/tx7do/go-utils/trans"

	"kratos-upload-file-example/app/admin/service/internal/data"

	adminV1 "kratos-upload-file-example/api/gen/go/admin/service/v1"
	fileV1 "kratos-upload-file-example/api/gen/go/file/service/v1"
)

type FileService struct {
	adminV1.FileServiceHTTPServer

	log *log.Helper

	mc *data.MinIOClient
}

func NewFileService(logger log.Logger, mc *data.MinIOClient) *FileService {
	l := log.NewHelper(log.With(logger, "module", "file/service/admin-service"))
	return &FileService{
		log: l,
		mc:  mc,
	}
}

func (s *FileService) OssUploadUrl(ctx context.Context, req *fileV1.OssUploadUrlRequest) (*fileV1.OssUploadUrlResponse, error) {
	return s.mc.OssUploadUrl(ctx, req)
}

到这里,服务的逻辑就实现好了。

前端的调用流程是:

  1. 前端向/admin/v1/file:upload-url这个API申请MinIO的预签名链接;
  2. 前端拿到了预签名的上传链接,向该链接上传文件。

直接向Kratos的服务上传文件

该方法的核心要点就是把文件打进FormData。后端服务解析FormData即可。

需要注意的是,Kratos的代码生成器不能够将Protobuf上传文件的API生成成go代码。这就是我在上面提到的需要手工代码的地方。

让我们先定义API:

protobuf 复制代码
syntax = "proto3";

package admin.service.v1;

import "gnostic/openapi/v3/annotations.proto";
import "google/api/annotations.proto";

// 文件服务
service FileService {
  // POST方法上传文件
  rpc PostUploadFile (stream UploadFileRequest) returns (UploadFileResponse) {
    option (google.api.http) = {
      post: "/admin/v1/file:upload"
      body: "*"
    };
  }

  // PUT方法上传文件
  rpc PutUploadFile (stream UploadFileRequest) returns (UploadFileResponse) {
    option (google.api.http) = {
      put: "/admin/v1/file:upload"
      body: "*"
    };
  }
}

message UploadFileRequest {
  optional string bucket_name = 1 [
    json_name = "bucketName",
    (gnostic.openapi.v3.property) = { description: "文件桶名称" }
  ]; // 文件桶名称

  optional string object_name = 2 [
    json_name = "objectName",
    (gnostic.openapi.v3.property) = { description: "文件名" }
  ]; // 文件名

  optional bytes file = 3 [
    json_name = "file",
    (gnostic.openapi.v3.property) = { description: "文件内容" }
  ]; // 文件内容
}

message UploadFileResponse {
  string url = 1;
}

当你生成了API的代码之后,你可以查看i_file_http.pb.go这个生成代码,你会发现,哦吼,里边并没有这两个接口的处理代码。于是,我们下面就需要手工搓一个,我们把它放到微服务的server包下:

go 复制代码
package server

import (
	"context"
	"io"
	"strings"

	"github.com/go-kratos/kratos/v2/transport/http"

	"kratos-upload-file-example/app/admin/service/internal/service"

	fileV1 "kratos-upload-file-example/api/gen/go/file/service/v1"
)

func registerFileUploadHandler(srv *http.Server, svc *service.FileService) {
	r := srv.Route("/")
	r.POST("admin/v1/file:upload", _FileService_PostUploadFile_HTTP_Handler(svc))
	r.PUT("admin/v1/file:upload", _FileService_PutUploadFile_HTTP_Handler(svc))
}

const OperationFileServicePostUploadFile = "/admin.service.v1.FileService/PostUploadFile"
const OperationFileServicePutUploadFile = "/admin.service.v1.FileService/PutUploadFile"

func _FileService_PostUploadFile_HTTP_Handler(svc *service.FileService) func(ctx http.Context) error {
	return func(ctx http.Context) error {
		http.SetOperation(ctx, OperationFileServicePostUploadFile)

		var in fileV1.UploadFileRequest
		var err error

		var aFile *fileV1.File

		file, header, err := ctx.Request().FormFile("file")
		if err == nil {
			defer file.Close()

			b := new(strings.Builder)
			_, err = io.Copy(b, file)

			aFile = &fileV1.File{
				FileName: header.Filename,
				Mime:     header.Header.Get("Content-Type"),
				Content:  []byte(b.String()),
			}
		}

		if err = ctx.BindQuery(&in); err != nil {
			return err
		}

		h := ctx.Middleware(func(ctx context.Context, req interface{}) (interface{}, error) {
			return svc.PostUploadFile(ctx, req.(*fileV1.UploadFileRequest), aFile)
		})

		// 逻辑处理,取数据
		out, err := h(ctx, &in)
		if err != nil {
			return err
		}

		reply := out.(*fileV1.UploadFileResponse)

		return ctx.Result(200, reply)
	}
}

func _FileService_PutUploadFile_HTTP_Handler(svc *service.FileService) func(ctx http.Context) error {
	return func(ctx http.Context) error {
		http.SetOperation(ctx, OperationFileServicePutUploadFile)

		var in fileV1.UploadFileRequest
		var err error

		var aFile *fileV1.File

		file, header, err := ctx.Request().FormFile("file")
		if err == nil {
			defer file.Close()

			b := new(strings.Builder)
			_, err = io.Copy(b, file)

			aFile = &fileV1.File{
				FileName: header.Filename,
				Mime:     header.Header.Get("Content-Type"),
				Content:  []byte(b.String()),
			}
		}

		if err = ctx.BindQuery(&in); err != nil {
			return err
		}

		h := ctx.Middleware(func(ctx context.Context, req interface{}) (interface{}, error) {
			return svc.PutUploadFile(ctx, req.(*fileV1.UploadFileRequest), aFile)
		})

		// 逻辑处理,取数据
		out, err := h(ctx, &in)
		if err != nil {
			return err
		}

		reply := out.(*fileV1.UploadFileResponse)

		return ctx.Result(200, reply)
	}
}

然后,把它注册进HTTP服务器:

go 复制代码
// NewRESTServer new an HTTP server.
func NewRESTServer(
	cfg *conf.Bootstrap,
	logger log.Logger,

	fileSvc *service.FileService,
) *http.Server {
    adminV1.RegisterFileServiceHTTPServer(srv, fileSvc)
    registerFileUploadHandler(srv, fileSvc)
}

在这个时候,我们才真正的拥有了这两个接口。

直接向MinIO上传文件,MinIO提供了两个接口:

  1. putObject 从流上传
  2. fPutObject 从文件上传

我们这里使用的是流式上传,所以使用的是putObject,将之封装一下,以供服务调用:

go 复制代码
func (c *MinIOClient) UploadFile(ctx context.Context, bucketName string, objectName string, file []byte) (string, error) {
	reader := bytes.NewReader(file)

	_, err := c.mc.PutObject(
		ctx,
		bucketName,
		objectName,
		reader, reader.Size(),
		minio.PutObjectOptions{},
	)
	if err != nil {
		return "", err
	}

	downloadUrl := "/" + bucketName + "/" + objectName

	return downloadUrl, nil
}

这时候就可以实现服务的实现代码了:

go 复制代码
func (s *FileService) PostUploadFile(ctx context.Context, req *fileV1.UploadFileRequest, file *fileV1.File) (*fileV1.UploadFileResponse, error) {
	if file == nil {
		return nil, fileV1.ErrorUploadFailed("unknown file")
	}

	if req.BucketName == nil {
		req.BucketName = trans.Ptr(s.mc.ContentTypeToBucketName(file.Mime))
	}
	if req.ObjectName == nil {
		req.ObjectName = trans.Ptr(file.FileName)
	}

	downloadUrl, err := s.mc.UploadFile(ctx, req.GetBucketName(), req.GetObjectName(), file.Content)
	return &fileV1.UploadFileResponse{
		Url: downloadUrl,
	}, err
}

func (s *FileService) PutUploadFile(ctx context.Context, req *fileV1.UploadFileRequest, file *fileV1.File) (*fileV1.UploadFileResponse, error) {
	if file == nil {
		return nil, fileV1.ErrorUploadFailed("unknown file")
	}

	if req.BucketName == nil {
		req.BucketName = trans.Ptr(s.mc.ContentTypeToBucketName(file.Mime))
	}
	if req.ObjectName == nil {
		req.ObjectName = trans.Ptr(file.FileName)
	}

	downloadUrl, err := s.mc.UploadFile(ctx, req.GetBucketName(), req.GetObjectName(), file.Content)
	return &fileV1.UploadFileResponse{
		Url: downloadUrl,
	}, err
}

前端只需要直接向这两个接口上传文件即可。

项目代码

相关推荐
wuk9981 小时前
互联网应用主流框架整合 Spring Boot开发
java·spring boot·后端
程序员NEO2 小时前
10分钟上线一个Web应用?我没开玩笑,用这个AI智能体就行
人工智能·后端
倔强青铜三2 小时前
Python的Lambda,是神来之笔?还是语法毒瘤?
人工智能·后端·python
a cool fish(无名)2 小时前
rust-方法语法
开发语言·后端·rust
随意石光3 小时前
秒杀功能、高并发系统关注的问题、秒杀系统设计
后端
随意石光3 小时前
Spring Cloud Alibaba Seata、本地事务、分布式事务、CAP 定理与 BASE 理论、Linux 安装 Seata、Seata的使用
后端
程序员清风3 小时前
程序员入职公司实习后应该学什么?
java·后端·面试
智慧源点3 小时前
基于DataX的数据同步实战
后端
随意石光3 小时前
Java操作Excel报表,EasyExcel用法大全
后端
大葱白菜3 小时前
Java 反射的作用详解:为什么说它是 Java 中最强大的特性之一?
java·后端·程序员