Go语言连接阿里云OSS实现对象存储客户端 | 青训营

957 阅读14分钟

一、理论介绍

1.对象存储:

在开发过程中由于数据有公共存储需求,存储需求量需要细化,同时存储量会随着用户增多变得巨大,就需要选择易用、海量、便宜的存储系统;由于数据量庞大,单机存储和单机数据库显然不能满足要求,而分布式数据库面向的是结构化数据,适用于存储管理具有多种数据的对象信息,这些信息都是KB级别,超过MB也会显得很吃力,因此需要选择专门针对海量数据设计的分布式存储;

分布式存储又具有分布式文件系统和对象存储之分,分布式文件系统能够存储PB到EB级别的海量数据,但是基于文件系统,文件数受NameNode限制,并且使用伪Posix文件接口,非云原生,开发难度大,搭建维护难,同时对于视频图片等大型文件相关生态差,接入复杂;对象存储能支持EB级别以上的海量数据,对象数量没有限制,使用Restful HTTP接口,云原生,开发使用简单,视频图片等的相关生态丰富,同时价格会更加低廉,因此对象存储方式是视频图片等海量存储的首选;

对象存储用于以高度可伸缩的方式存储和检索大量非结构化数据,例如文档、图像、音频和视频文件等,它不依赖于传统的文件系统层次结构或块级存储卷,它将数据存储为对象,每个对象包含数据、元数据(描述对象的信息)以及一个唯一标识符(通常称为对象键或对象ID),由于对象存储使用分布式存储,数据分布在多个物理位置,保证了数据的可靠性和持久化,同时对象存储通常使用 HTTP 或 HTTPS 协议来访问和检索数据,数据操作方便。

2.阿里云OSS:

链接(有新人3个月免费试用活动): 对象存储 OSS_云存储服务_企业数据管理_存储-阿里云 (aliyun.com)

阿里云对象存储服务(Alibaba Cloud Object Storage Service,简称OSS),是阿里云提供的高度可扩展的云端对象存储服务,提供了多语言SDK(OSS管理控制台 (aliyun.com)),提供了丰富的OSS操作接口,能够通过SDK自带函数直接实现上传、下载、列举、删除等操作,同时支持数据迁移、数据加密、访问控制、数据生命周期管理等操作,使用非常方便,可以支持大文件的分片上传和断点续传,性能高效;附带详细的文档和示例代码(Go SDK文档:OSSGoSDK示例代码_对象存储 OSS-阿里云帮助中心 (aliyun.com)),并且云原生集成,能够很好的与阿里云的其他云服务集成使用,也支持跨平台使用。

3.实战需求:

使用Go语言连接阿里云OSS实现一个简单的命令行存储对象客户端,要求能够实现以下功能:

  • 创建对象:创建时超过1GB的对象需要使用MultiUpload即分片上传的方式,小于1GB的用普通的PUT
  • 下载对象;
  • 删除对象;
  • 查看对象是否存在;
  • 列举对象;
  • 列举CommonPrefix:CommonPrefixes表示与给定前缀匹配的对象键的一部分,是前缀下的子目录或对象键的集合,是一个用于组织对象的元数据字段,通常用于构建虚拟目录结构。

二、实战过程

1.申请对象存储Bucket:

购买或试用成功后,进入控制台选择对象存储OSS,选择Bucket列表,点击创建Bucket,填入Bucket名称,按自己的需求选择其他附加服务后即可成功创建:

image.png

进入Bucket后可以看到文件列表,可以直接通过该界面进行一些数据交互和属性设置:

image.png

申请完Bucket后还需要创建AccessKey即云服务访问密钥,以供自己的程序可以访问该OSS,创建后会显示 AccessKey IDAccessKey Secret,需要自己记录AccessKey Secret以防丢失;

image.png

image.png

2.Golang连接阿里云OSS:

首先下载并导入Go SDK:

源码地址:aliyun/aliyun-oss-go-sdk: Aliyun OSS SDK for Go (github.com)

下载指令:go get github.com/aliyun/aliyun-oss-go-sdk

导入SDK包:import "github.com/aliyun/aliyun-oss-go-sdk/oss"

代码中需要先设置四个字段:

  • accessKeyID:密钥ID;
  • accessKeySecret:密钥内容;
  • endpoint: 阿里云OSS服务的访问终端点,指定了要连接的OSS服务的位置;如我创建Bucket时选择了华北2(北京),就需要填写为oss-cn-beijing.aliyuncs.com
  • bucketName:设置的Bucket容器名称;

接下来连接到阿里云OSS的客户端(使用oss.New函数),然后创建存储桶对象的引用(使用Bucket函数):

func main() {
	accessKeyID := ""
	accessKeySecret := ""
	endpoint := "oss-cn-beijing.aliyuncs.com"
	bucketName := "l-test-bucket"

	client, err := oss.New(endpoint, accessKeyID, accessKeySecret)
	if err != nil {
		fmt.Println("创建OSS客户端时发生错误:", err)
		return
	}

	bucket, err := client.Bucket(bucketName)
	if err != nil {
		fmt.Println("创建存储桶对象时发生错误:", err)
		return
	}
}

3.上传对象:

阿里云OSS提供了多种上传文件的接口函数,文档也提供了很多示例代码:

文档地址:aliyun-oss-go-sdk/sample/put_object.go at master · aliyun/aliyun-oss-go-sdk (github.com)

常用接口函数:

  • bucket.PutObject(objectKey string, reader io.Reader, options ...oss.Option):用于将对象上传到OSS存储桶。可以使用不同的options来设置对象的属性,如ACL(访问控制)、过期时间、元数据等;
  • bucket.PutObjectFromFile(objectKey string, filePath string, options ...oss.Option) :用于从本地文件上传对象到OSS存储桶。可以使用options设置对象的属性;
  • bucket.UploadFile(objectKey string, filePath string, partSize int64, options ...oss.Option) :用于大文件的分片上传到 OSS 存储桶,支持并发上传和断点续传。

普通对象上传:

选择使用bucket.PutObjectFromFile函数,上传单个文件到存储桶,并且设置文件的读写权限为公共读写;

func uploadToOSS(bucket *oss.Bucket, objectName, localFilePath string) {
	//上传本地文件到OSS并设置公共读权限
	err := bucket.PutObjectFromFile(objectName, localFilePath, oss.ObjectACL(oss.ACLPublicRead))
	if err != nil {
		fmt.Errorf("上传文件到OSS时发生错误: %v", err)
	}
	fmt.Println("文件已成功上传到OSS")
}

大文件 MultiUpload分片上传:

按三个步骤依次进行分片上传:

  1. InitiateMultipartUpload(objectName):初始化分片上传事件,返回一个分片上传事件的标识符,告诉OSS服务将要开启一个新的分片上传;
  2. UploadPart(imur, fd, chunk.Size, chunk.Number),将分片逐个上传到OSS桶;
  3. CompleteMultipartUpload(imur, parts, oss.GetResponseHeader(&retHeader)):完成分片上传,告诉OSS已经完成所有分片上传,并将获取的分片组合成完整的对象,返回相关的元数据;
var retHeader http.Header

	chunks, err := oss.SplitFileByPartNum(localFilePath, 3)
	fd, err := os.Open(localFilePath)
	defer fd.Close()

	// 步骤1:初始化一个分片上传事件。
	imur, err := bucket.InitiateMultipartUpload(objectName)
	// 步骤2:上传分片。
	var parts []oss.UploadPart
	for _, chunk := range chunks {
		fd.Seek(chunk.Offset, io.SeekStart)
		// 对每个分片调用UploadPart方法上传。
		part, err := bucket.UploadPart(imur, fd, chunk.Size, chunk.Number)
		if err != nil {
			fmt.Println("Error:", err)
			os.Exit(-1)
		}
		parts = append(parts, part)
	}
	// 步骤3:完成分片上传。
	cmur, err := bucket.CompleteMultipartUpload(imur, parts, oss.GetResponseHeader(&retHeader))
	if err != nil {
		fmt.Println("Error:", err)
		os.Exit(-1)
	}
	fmt.Println("cmur:", cmur)
	// 打印x-oss-version-id。
	fmt.Println("x-oss-version-id:", oss.GetVersionId(retHeader))
        fmt.Println("文件已成功通过分片方式上传到OSS")

区分大文件和普通文件:

需求要求的是1GB以上的文件需要采用分片上传,就需要上传文件时判断文件的大小再选择合适的方法进行上传,Golang提供了os.Stat的方法获取获取文件的文件信息,返回一个FileInfo结构和error,FileInfo 结构包含了关于文件的各种信息:

  • Name:返回文件的名称(不包括路径);
  • Size:返回文件的大小(字节数);
  • Mode:返回文件的权限和模式;
  • ModTime:返回文件的修改时间;
  • IsDir:检查文件是否为目录;

因此通过上述的Size函数可以获取到文件的字节数,而1GB等于2^30字节,因此使用math.Pow函数maxsize := int64(math.Pow(2, 30))可以表示1GB的字节数,转换为int64类型是由于Size函数是int64类型,为了进行比较需要转换为同类型,利用这些方法就可以实现文件根据不同大小选择不同方式上传;

最后完整的上传函数为:

func uploadToOSS(bucket *oss.Bucket, objectName, localFilePath string) {
	maxsize := int64(math.Pow(2, 30))
	file, err := os.Stat(localFilePath)
	if err != nil {
		fmt.Println("打开文件错误,请检查文件路径")
                return
	}
	size := file.Size()

	if size <= maxsize {
		// 上传本地文件到OSS并设置公共读权限
		err = bucket.PutObjectFromFile(objectName, localFilePath, oss.ObjectACL(oss.ACLPublicRead))
		if err != nil {
			fmt.Errorf("上传文件到OSS时发生错误: %v", err)
		}
		fmt.Println("文件已成功上传到OSS")
	} else {
		// 用oss.GetResponseHeader获取返回header。
		var retHeader http.Header
                
		chunks, err := oss.SplitFileByPartNum(localFilePath, 3)
		fd, err := os.Open(localFilePath)
		defer fd.Close()

		// 步骤1:初始化一个分片上传事件。
		imur, err := bucket.InitiateMultipartUpload(objectName)
		// 步骤2:上传分片。
		var parts []oss.UploadPart
		for _, chunk := range chunks {
			fd.Seek(chunk.Offset, io.SeekStart)
			// 对每个分片调用UploadPart方法上传。
			part, err := bucket.UploadPart(imur, fd, chunk.Size, chunk.Number)
			if err != nil {
				fmt.Println("Error:", err)
				os.Exit(-1)
			}
			parts = append(parts, part)
		}
		// 步骤3:完成分片上传。
		cmur, err := bucket.CompleteMultipartUpload(imur, parts, oss.GetResponseHeader(&retHeader))
		if err != nil {
			fmt.Println("Error:", err)
			os.Exit(-1)
		}
		fmt.Println("cmur:", cmur)
          fmt.Println("文件已成功通过分片方式上传到OSS")
	}
}

4.下载对象:

文档地址:aliyun-oss-go-sdk/sample/get_object.go at master · aliyun/aliyun-oss-go-sdk (github.com)

常用接口函数:

  • bucket.GetObject(objectKey):该函数用于以流式方式下载对象,返回一个ReadCloser,需要在下载数据后进行读取和关闭;
  • bucket.GetObject(objectKey, oss.Range(15, 19)):该函数用于下载对象的特定范围,同样返回一个ReadCloser
  • bucket.GetObject(objectKey)io.Copy(buf, body):这个组合用于将对象的内容下载到字节缓冲区中;
  • bucket.GetObject(objectKey)io.Copy(fd, body):这个组合用于下载对象并保存到由文件描述符fd指定的本地文件中;
  • bucket.GetObjectToFile(objectKey, "mynewfile-2.jpg"):该函数用于将对象直接下载到指定文件名的本地文件中;
  • bucket.DownloadFile(objectKey, "mynewfile-3.jpg", ...):该函数用于对大文件进行分块下载,支持并行和可恢复下载,使用不同参数的函数变体来控制下载行为,包括分片大小、协程数量和检查点;
  • bucket.GetObjectToFile(objectKey, "myhtml.gzip", oss.AcceptEncoding("gzip")):该函数用于下载使用GZIP编码的文件。

为了更好的实现命令行客户端交互,选择使用GetObjectToFile函数,将对象下载到用户指定的文件中;

func downloadFromOSS(bucket *oss.Bucket, objectName, localFilePath string) {
	//从OSS下载文件到本地
	err := bucket.GetObjectToFile(objectName, localFilePath)
	if err != nil {
		fmt.Errorf("从OSS下载时发生错误:%v", err)
	}
	fmt.Println("文件已成功下载")
}

5.删除对象:

文档地址:aliyun-oss-go-sdk/sample/delete_object.go at master · aliyun/aliyun-oss-go-sdk (github.com)

常用接口函数:

  • bucket.DeleteObject(objectKey):该函数用于删除单个对象;
  • bucket.DeleteObjects([]string{objectKey + "1", objectKey + "2"}):该函数用于删除多个对象,可以传入对象的键的列表;
  • bucket.DeleteObjects([]string{objectKey + "1", objectKey + "2"},oss.DeleteObjectsQuiet(false)):该函数用于删除多个对象,并以详细模式返回已删除的对象信息;
  • bucket.DeleteObjects([]string{objectKey + "1", objectKey + "2"},oss.DeleteObjectsQuiet(true)):该函数用于删除多个对象,并以静默模式返回已删除的对象信息。

使用单个删除的方式,删除用户指定的对象:

func deleteFromOSS(bucket *oss.Bucket, objectName string) {
	err := bucket.DeleteObject(objectName)
	if err != nil {
		fmt.Errorf("删除对象发生错误:%v", err)
	}
	fmt.Println("对象已删除")
}

6.查看对象是否存在:

SDK直接提供了判断对象是否存在的接口bucket.IsObjectExist(objectName),只需要提供对象名称,存在则返回true,不存在返回false;

func isExist(bucket *oss.Bucket, objectName string) {
	exists, err := bucket.IsObjectExist(objectName)
	if err != nil {
		log.Fatal("查看对象是否存在时发生错误:", err)
	}
	if exists {
		fmt.Println("对象存在")
	} else {
		fmt.Println("对象不存在")
	}
}

7.列举对象:

文档地址:aliyun-oss-go-sdk/sample/delete_object.go at master · aliyun/aliyun-oss-go-sdk (github.com)

常用接口函数:

  • bucket.ListObjects(): 使用默认参数列举对象。它返回oss.ListObjectsResult结构,该结构包含以下信息:
    • IsTruncated (bool): 表示是否还有更多的对象需要列举,如果为 true,则表示还有未列举的对象;
    • NextMarker (string): 如果列表被截断,这个字段会包含一个标记,用于指示从哪个对象键开始继续列举;
    • Objects ([]oss.ObjectProperties): 包含列举的对象的属性列表,每个对象的属性包括对象键Key等信息;
    • CommonPrefixes ([]string): 如果使用分隔符进行分组列举对象,这个字段包含常见的前缀列表,用于指示存储桶中存在的共同前缀,比如目录结构;
    • Prefix (string): 如果使用前缀进行过滤列举对象,这个字段会返回所使用的前缀;
    • Marker (string): 用于指示从哪个对象键开始列举的标记;
    • Delimiter (string): 如果使用分隔符进行分组列举对象,这个字段会返回所使用的分隔符;
  • bucket.ListObjects(oss.MaxKeys(3)): 指定最大返回的对象数,以列举对象;
  • bucket.ListObjects(oss.Prefix("my-object-2")): 指定对象键的前缀,以列举以该前缀开头的对象;
  • bucket.ListObjects(oss.Marker("my-object-22")): 指定一个标记,以从标记位置开始列举对象;
  • bucket.ListObjects(oss.MaxKeys(3), marker): 用于分页列举对象,每页返回指定数量的对象,并使用进行分页;
  • bucket.ListObjects(oss.MaxKeys(3), marker): 用于分页列举对象,同时指定一个标记,以从标记位置开始列举对象,并每页返回指定数量的对象;
  • bucket.ListObjects(oss.MaxKeys(2), marker, pre): 用于分页列举对象,同时指定前缀和最大返回对象数,以分页列举对象;
  • bucket.ListObjects(oss.Prefix("fun/"), oss.Delimiter("/")): 使用前缀和分隔符进行分组列举对象,返回匹配前缀的对象以及常见的前缀;
func listObject(bucket *oss.Bucket) {
	// 列举对象
	objectList, err := bucket.ListObjects()
	if err != nil {
		log.Fatal("列举对象时发生错误:", err)
	}
	fmt.Println("对象列表:")
	for _, obj := range objectList.Objects {
		fmt.Println(obj.Key)
	}
}

8.列举CommonPrefixes:

使用上述列举对象中的函数bucket.ListObjects(oss.Prefix("fun/"), oss.Delimiter("/"))列举CommonPrefixes

func GetCommonPrefixes(bucket *oss.Bucket, prefix string) {
	// 获取CommonPrefixes
	commonPrefixes, err := bucket.ListObjects(oss.Prefix(prefix))
	if err != nil {
		log.Fatal("获取CommonPrefixes时发生错误:", err)
	}
	fmt.Println("CommonPrefixes:")
	for _, ob := range commonPrefixes.Objects {
		fmt.Println(ob.Key)
	}
}

9.命令行客户端实现:

在主函数中输出提示用户选择所需操作,利用switch语句获取选择并提示用户输入所需参数,调用相应的函数进行响应:


func main() {
	accessKeyID := ""
	accessKeySecret := ""
	endpoint := "oss-cn-beijing.aliyuncs.com"
	bucketName := "l-test-bucket"

	client, err := oss.New(endpoint, accessKeyID, accessKeySecret)
	if err != nil {
		fmt.Println("创建OSS客户端时发生错误:", err)
		return
	}

	bucket, err := client.Bucket(bucketName)
	if err != nil {
		fmt.Println("创建存储桶对象时发生错误:", err)
		return
	}

	for {
		fmt.Println("----------------------")
		fmt.Println("请选择操作:")
		fmt.Println("1. 上传文件到OSS")
		fmt.Println("2. 从OSS下载文件")
		fmt.Println("3. 从OSS删除文件")
		fmt.Println("4. 检查对象是否存在")
		fmt.Println("5. 列举OSS对象")
		fmt.Println("6. 获取CommonPrefixes")
		fmt.Println("0. 退出")

		var choice int
		fmt.Print("输入操作编号: ")
		_, err := fmt.Scan(&choice)
		if err != nil {
			fmt.Println("无效的输入:", err)
			continue
		}

		switch choice {
		case 1:
			fmt.Print("请输入本地文件路径: ")
			var localFilePath string
			fmt.Scan(&localFilePath)
			fmt.Print("请输入要上传到OSS的对象名称: ")
			var objectName string
			fmt.Scan(&objectName)
			uploadToOSS(bucket, objectName, localFilePath)

		case 2:
			fmt.Print("请输入要从OSS下载的对象名称: ")
			var objectName string
			fmt.Scan(&objectName)
			fmt.Print("请输入保存到的本地文件路径: ")
			var localFilePath string
			fmt.Scan(&localFilePath)
			downloadFromOSS(bucket, objectName, localFilePath)

		case 3:
			fmt.Print("请输入要从OSS删除的对象名称: ")
			var objectName string
			fmt.Scan(&objectName)
			deleteFromOSS(bucket, objectName)

		case 4:
			fmt.Print("请输入要检查的对象名称: ")
			var objectName string
			fmt.Scan(&objectName)
			isExist(bucket, objectName)

		case 5:
			listObject(bucket)

		case 6:
			fmt.Print("请输入要获取CommonPrefixes的前缀: ")
			var prefix string
			fmt.Scan(&prefix)
			GetCommonPrefixes(bucket, prefix)

		case 0:
			fmt.Println("程序退出")
			os.Exit(0)

		default:
			fmt.Println("无效的选择,请重新输入")
		}
	}
}

10.运行情况:

上传操作:

  • 小于1GB上传: image.png

image.png

  • 由于没有比较合适的1GB大小的文件进行测试,将代码改成了大于20MB就分片上传进行测试:

image.png

  • 在上传过程中,打开控制台的碎片管理可以看到分片情况:

image.png

image.png

下载操作:

image.png

image.png

删除操作:

image.png

image.png

查看对象是否存在:

image.png

列举对象:

不仅可以列举出对象还能列举出创建的目录:

image.png

列举CommonPrefixes:

可列举出相同前缀的目录和对象,目录内的相同前缀对象也会被列举出来,但是非相同前缀的目录,即使其中有相同前缀的对象,也无法被列举:

image.png

三、总结

由于阿里OSS的SDK的完备,整体实现方式并不复杂,很多操作都能直接通过SDK提供的函数直接实现;不过通过这样一个实践,让我更深入的了解了一个对象存储系统需要实现的操作,了解到其内部很类似于key-value存储的实现,每个数据对象都有一个便于访问的key字段也就是对象名称;也感受到对象存储对海量数据的支持,上传比较大的视频图片,通过分片等方式能够快速的将数据持久化,同时也自己按步骤实现了MultiUpload的“闪电三连鞭”,了解分片上传的流程;

在实践过程中也可以看到,不论通过控制台还是程序上传的文件,上传成功后都会自动生成HTTP/HTTPS协议的文件URL,访问数据对象非常方便,让我想到可以在大项目中使用OSS存储视频图片,数据库保存URL,访问时直接通过OSS访问数据,这样可以避免使用静态资源访问,上传和访问的速度应该也会有很大的提升;

通过实践学习到了很多对象存储的使用方法,虽然只针对了阿里云OSS,但是可以肯定,无论是什么对象存储系统,需要的操作都是大同小异,都只会在用法细节和实现细节上有所区别,整体操作肯定是没有差别的,整理操作方法并实现这样的一个简单OSS客户端还是很有意义的。