开箱即用的 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
}

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

项目代码

目录
相关文章
|
SQL 数据库 Nacos
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:代码生成工具集
GoWind Admin|风行是一套企业级前后端一体中后台框架,配套代码生成工具集,支持配置导出、数据库转ORM/Proto/Kratos服务代码,提升开发效率,开箱即用。
572 0
|
SQL API 容器
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:后端项目结构说明
GoWind Admin|风行是一款企业级前后端一体中后台框架,后端采用Go语言,基于Protobuf定义API,支持Buf代码生成与Docker部署。项目结构清晰,含API定义、服务实现、数据库ORM、配置管理及自动化脚本,开箱即用,助力高效开发。
664 0
|
存储 前端开发 Go
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:站内信
GoWind Admin 基于 Kratos 框架,提供开箱即用的站内信模块,支持消息推送、实时通知、分类管理与多用户收发,助力企业级后台高效集成通信功能。
908 0
|
8月前
|
前端开发 安全 Go
GoWind Admin|风行 — 开箱即用的企业级全栈中后台框架・内置微服务接口数据聚合能力
GoWind Admin(风行)是一款开箱即用的企业级全栈中后台框架,专为微服务场景设计。内置高性能、类型安全的数据聚合引擎,支持并发拉取、智能回填、树形结构与DataLoader模式,一键解决N+1查询与跨服务数据拼装难题,大幅提升开发效率与系统性能。
533 2
|
人工智能 安全 Cloud Native
Nacos 3.0 架构升级,AI 时代更安全的 Registry
随着Nacos3.0的发布,定位由“更易于构建云原生应用的动态服务发现、配置管理和服务管理平台”升级至“ 一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台 ”。
|
存储 运维 安全
金融级 ZooKeeper 来袭:性能提升100%,SLA 99.99%,数据防护升级
阿里云微服务引擎 MSE ZooKeeper 企业版正式发布,提供比专业版更高的稳定性与安全能力,SLA 达 99.99%,整体服务性能提升 100%。针对关键业务,企业版通过独享资源池实现更高规格配额,满足大规模需求。此外新增数据备份容灾、容量管理反脆弱限流等功能,提升整体企业级特性,助力企业应对复杂业务挑战。
643 138
金融级 ZooKeeper 来袭:性能提升100%,SLA 99.99%,数据防护升级
|
运维 Cloud Native 应用服务中间件
阿里云微服务引擎 MSE 及 API 网关 2025 年 8 月产品动态
阿里云微服务引擎 MSE 面向业界主流开源微服务项目, 提供注册配置中心和分布式协调(原生支持 Nacos/ZooKeeper/Eureka )、云原生网关(原生支持Higress/Nginx/Envoy,遵循Ingress标准)、微服务治理(原生支持 Spring Cloud/Dubbo/Sentinel,遵循 OpenSergo 服务治理规范)能力。API 网关 (API Gateway),提供 APl 托管服务,覆盖设计、开发、测试、发布、售卖、运维监测、安全管控、下线等 API 生命周期阶段。帮助您快速构建以 API 为核心的系统架构.满足新技术引入、系统集成、业务中台等诸多场景需要。
663 152
|
JSON Cloud Native Go
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:后端权限控制
GoWind Admin「风行」是企业级中后台框架,集成Casbin与OPA实现API权限控制。支持RBAC、ABAC等模型,开箱即用,助力高效构建安全的前后端一体化系统。
1082 1
|
存储 SQL Cloud Native
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:支持InfluxDB
GoWind Admin(风行)是企业级前后端一体中后台框架,支持InfluxDB 3.x,专为时间序列数据优化。内置IOx引擎、SQL查询、云原生架构,助力高并发、实时分析场景,开箱即用,快速构建高效应用。
587 0
|
前端开发 Go API
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:数据脱敏和隐私保护
GoWind Admin基于Protobuf生态,集成protoc-gen-redact插件,实现开箱即用的数据脱敏与隐私保护。通过注解定义规则,自动生成脱敏代码,支持多语言、灵活配置,零侵入业务逻辑,适用于微服务、日志、前端等场景,保障数据安全合规。
410 0