开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:如何上传文件

简介: GoWind Admin(风行)是企业级中后台框架,支持文件上传功能。通过集成MinIO,实现安全高效的文件管理,提供预签名URL直传与服务端中转两种模式,推荐使用减轻后端压力的直连MinIO方案,提升系统性能与安全性。

开箱即用的 GoWind 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 操作预签名上传链接以供上传

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

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:

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之后,接着来实现服务:

package service

import (
    "context"

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

    "go-wind-admin/app/admin/service/internal/data"

    adminV1 "go-wind-admin/api/gen/go/admin/service/v1"
    fileV1 "go-wind-admin/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:

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包下:

package server

import (
    "context"
    "io"
    "strings"

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

    "go-wind-admin/app/admin/service/internal/service"

    fileV1 "go-wind-admin/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服务器:

// 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,将之封装一下,以供服务调用:


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
}

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


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
}

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

项目代码

目录
相关文章
|
前端开发 JavaScript Go
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:介绍
风行(GoWind Admin)是一款开箱即用的企业级Golang全栈中后台框架,基于go-kratos与Vben Admin,支持单体/微服务架构。功能完备,涵盖用户、租户、角色、权限、日志等管理模块,助力开发者高效构建管理系统。
825 0
|
SQL 数据库 Nacos
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:代码生成工具集
GoWind Admin|风行是一套企业级前后端一体中后台框架,配套代码生成工具集,支持配置导出、数据库转ORM/Proto/Kratos服务代码,提升开发效率,开箱即用。
318 0
|
前端开发 JavaScript 数据安全/隐私保护
推荐6款超级好看的开源中后台前端框架
推荐6款超级好看的开源中后台前端框架
2190 0
|
物联网 测试技术 网络性能优化
MQTT常见问题之收不到MQTT消息如何解决
MQTT(Message Queuing Telemetry Transport)是一个轻量级的、基于发布/订阅模式的消息协议,广泛用于物联网(IoT)中设备间的通信。以下是MQTT使用过程中可能遇到的一些常见问题及其答案的汇总:
|
7月前
|
JSON Cloud Native Go
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:后端权限控制
GoWind Admin「风行」是企业级中后台框架,集成Casbin与OPA实现API权限控制。支持RBAC、ABAC等模型,开箱即用,助力高效构建安全的前后端一体化系统。
586 1
|
7月前
|
JSON API Go
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:集成 Swagger UI 打造交互式 API 文档
GoWind Admin(风行)是基于 Kratos 的企业级中后台框架,集成 Swagger UI 实现交互式 API 文档。通过 Protobuf 自动生成 OpenAPI v3 规范文档,利用 `//go:embed` 嵌入服务,一键部署可视化调试界面,提升前后端协作效率,开箱即用。
420 2
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:集成 Swagger UI 打造交互式 API 文档
|
SQL API 容器
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:后端项目结构说明
GoWind Admin|风行是一款企业级前后端一体中后台框架,后端采用Go语言,基于Protobuf定义API,支持Buf代码生成与Docker部署。项目结构清晰,含API定义、服务实现、数据库ORM、配置管理及自动化脚本,开箱即用,助力高效开发。
308 0
|
前端开发 Go API
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:数据脱敏和隐私保护
GoWind Admin基于Protobuf生态,集成protoc-gen-redact插件,实现开箱即用的数据脱敏与隐私保护。通过注解定义规则,自动生成脱敏代码,支持多语言、灵活配置,零侵入业务逻辑,适用于微服务、日志、前端等场景,保障数据安全合规。
131 0
|
1月前
|
前端开发 JavaScript Go
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:为什么选 Golang+Vue3 这套组合?
go-wind-admin 采用 Golang + Vue3 技术栈,融合高性能后端与高效前端生态。后端基于 go-kratos、ent/gorm 灵活适配复杂业务,前端结合 Vue3、TypeScript 与 Vben Admin,提升开发效率与可维护性,兼顾性能、扩展性与企业级需求,是中后台系统的理想选择。(239字)
212 6
|
前端开发 安全 API
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:自动化解放双手,初学者快速搭建系统并自动生成前端接口
GoWind Admin 是基于 Go-Kratos 与 Vue3 的企业级中后台框架,开箱即用,集成用户、权限、租户等核心模块。搭配 protoc-gen-typescript-http,可从 Protobuf 自动生成类型安全的前端接口,大幅降低联调成本,提升开发效率,助力初学者快速搭建系统,实现前后端高效协作。
238 0