系统方式开发
- 每个区域一个
Bucket, 用于内网访问加速 API server控制用户是否可以上传和下载API server保存OSS、AccessKey和AccessSecret, 避免泄露风险- 和其他系统集成
因此我们需要开发的系统组件有:
- 客户端工具(CLI)
- API Server控制器 (本章节不实现)
不同于工具开发, 系统开发的思维访问 更偏向于系统设计与业务抽象, 基于系统模式我们来重构刚才的简单版
本节工作目录
与上一节在同一工作目录
客户端核心组件模块: store
抽象业务模型 interface.go
为了屏蔽多个 云厂商OSS 操作的差异,我们抽象出一个 store组件, 他用于解决 文件的上传和下载问题, 因此我们为定义一个 Uploader接口
type Uploader interface {
// 上传文件到后端存储
UploadFile(bucketName, objectKey, localFilePath string) error
}
插件规划
如果想要作为 cloud-station 的存储插件,就必须实现这个 uploader接口, 我们有多少种插件
- 腾讯云: qcloud
- 阿里云: aliyun
- 自己搭建的oss: mini
阿里云插件开发(初识TDD) store/aliyun
编写插件骨架
迁移我们之前开发阿里云的上传函数为一个插件实现: store.go
插件作为 uploader 的一个实现方,必须实现 uploader 定义的函数, 因此我们定义对象来实现它
// 构造函数
func NewUploader() store.Uploader {
return &aliyun{}
}
type aliyun struct{}
func (p *aliyun) UploadFile(bucketName, objectKey, localFilePath string) error {
return fmt.Errorf("not impl")
}
这样我们就实现了一个阿里云的 uploader 实例, 但是这个实例能不能正常工作喃? 对我们需要写测试用例, 也就是我们常说的 TDD(测试驱动开发) 的开发流程
func TestUpload(t *testing.T) {
fmt.Println("hello test detail log")
}
为插件编写测试用例
编写实例的测试用例: store_test.go
var (
bucketName = ""
objectKey = ""
localFilePath = ""
)
func TestUploadFile(t *testing.T) {
should := assert.New(t)
uploader := aliyun.NewUploader()
err := uploader.UploadFile(bucketName, objectKey, localFilePath)
should.NoError(err)
}
完善插件逻辑, 直到测试用例通过
- 封装创建 OSS Client 的参数 并为其构建校验函数
type Options struct{
Endpoint string
AccessKey string
AccessSecret string
}
func (o *Options) Validate() error {
if o.Endpoint == "" || o.AccessKey == "" || o.AccessSecret == "" {
return fmt.Errorf("endpoint, access_key access_secret has one empty")
}
return nil
}
- 创建实现约束的 OSS Client对象
type AliOssStore struct{
client *oss.Client
}
func(s *AliOssStore) Upload(bucketName, objectKey, fileName string) error{
bucket, err := s.client.Bucket(bucketName)
if err != nil{
return err
}
if err := bucket.PutObjectFromFile(objectKey, fileName); err != nil{
return err
}
downloadURL, err := bucket.SignURL(fileName, oss.HTTPGet, 60*60*24)
if err != nil {
return err
}
fmt.Printf("文件下载URL: %s \n", downloadURL)
fmt.Println("请在1天之内下载.")
return nil
}
- 如何判断是否实现约束
- 使用下划线,因为我们只是来确定该对象是否实现约束而并不使用它
- 通过对nil的强制类型转换,减少占用空间
var (
_ store.Uploader = (*AliOssStore)(nil)
)
- 构造函数
func NewAliOssStore(opts *Options) (*AliOssStore, error){
// 校在参数
if err := opts.Validate(); err != nil {
return nil, err
}
c, err := oss.New(opts.Endpoint, opts.AccessKey, opts.AccessSecret)
if err != nil {
return nil, err
}
return &AliOssStore{
client: c,
}, nil
}
func NewDefaultAliOssStore() (*AliOssStore, error) {
return NewAliOssStore(&Options{
Endpoint: os.Getenv("ALI_OSS_ENDPOINT"),
AccessKey: os.Getenv("ALI_AK"),
AccessSecret: os.Getenv("ALI_SK"),
})
}
完善测试用例 store_test.go
- 初始化
var (
uploader store.Uploader
)
var (
AccessKey = os.Getenv("ALI_AK")
AccessSecret = os.Getenv("ALI_SK")
OssEndpoint = os.Getenv("ALI_OSS_ENDPOINT")
BucketName = os.Getenv("ALI_BUCKET_NAME")
)
// 通过init 编写 uploader 实例化逻辑
func init() {
ali, err := aliyun.NewDefaultAliOssStore()
if err != nil {
panic(err)
}
uploader = ali
}
- 测试上传功能
- 我们使用
assert编写测试用例的断言语句 - 通过New 获取一个断言实例
// Aliyun Oss Store Upload测试用例
func TestUpload(t *testing.T) {
should := assert.New(t)
err := uploader.Upload(BucketName, "test.txt", "store_test.go")
if should.NoError(err) {
// 没有Error 开启下一个步骤
t.Log("upload ok")
}
}
- 测试上传失败的断言
func TestUploadError(t *testing.T) {
should := assert.New(t)
err := uploader.Upload(BucketName, "test.txt", "store_testxxx.go")
should.Error(err, "open store_testxxx.go: The system cannot find the file specified.")
}
测试用例的debug调试
如果出现难以理解的调试结果, 你就需要debug了, vscode 测试用例的debug很简单, 总共2步就可以开启debug调试
- 添加断点, 断点处必须有代码
- 点击测试用例上方的 debug test文字
这是解决疑难杂症的利器,一定要会
到此 我们的aliyun的uploader插件就开发完成, 并且有一个基本的测试用例保证其质量
快去试试吧!
客户端用户接口CLI
我们要把程序 交付给用户使用,需要为其提供交互接口, 交互的方式有很多, API, CLI, GUI, 现在我们为CLI交付
简单版本中,我们直接使用flag, 简单场景下已经足够我们使用了, 如果我们有很多命令,flag 用起来就由很多工作了, 比如docker的cli
$ docker
Usage: docker [OPTIONS] COMMAND
A self-sufficient runtime for containers
Management Commands:
app* Docker App (Docker Inc., v0.9.1-beta3)
builder Manage builds
buildx* Build with BuildKit (Docker Inc., v0.5.1-docker)
compose* Docker Compose (Docker Inc., 2.0.0-beta.1)
config Manage Docker configs
container Manage containers
context Manage contexts
image Manage images
manifest Manage Docker image manifests and manifest lists
network Manage networks
node Manage Swarm nodes
plugin Manage plugins
scan* Docker Scan (Docker Inc., v0.8.0)
secret Manage Docker secrets
service Manage services
stack Manage Docker stacks
swarm Manage Swarm
system Manage Docker
trust Manage trust on Docker images
volume Manage volumes
重构版 我们使用 github.com/spf13/cobra 作为我们的 CLI 框架
- Cobra 的使用教程:GO 命令行工具Cobra简单使用 - 掘金 (juejin.cn)
添加root命令, 打印使用说明
在主目录树下我们只展现方法列表和版本信息
var (
version bool
)
var RootCmd = cobra.Command{
Use: "cloud-station-cli",
Long: "cloud-station-cli 云中转站",
Short: "cloud-station-cli 云中转站",
Example: "cloud-station-cli cmds",
RunE: func(cmd *cobra.Command, args []string) error {
if version{
fmt.Println("cloud-station-cli v0.01")
return nil
}
return errors.New("no flag find")
},
}
func init(){
f := RootCmd.PersistentFlags()
f.BoolVarP(&version, "version", "v", false, "cloud-station版本信息")
}
验证下效果
添加upload命令
- 定义 flag
var (
ossProvier string
ossEndpoint string
accessKey string
accessSecret string
bucketName string
uploadFile string
help bool
)
func init(){
f := UploadCmd.PersistentFlags()
f.StringVarP(&ossProvier, "provider", "p", "aliyun", "oss供应商(现仅支持阿里云)")
f.StringVarP(&ossEndpoint, "endpoint", "e", "", "oss 供应商 endpoint")
f.StringVarP(&bucketName, "bucket_name", "b", "", "oss 供应商 bucket name")
f.StringVarP(&accessKey, "access_key", "k", "", "oss 供应商 ak")
f.StringVarP(&accessSecret, "access_secret", "s", "", "oss 供应商 sk")
f.StringVarP(&uploadFile, "upload_file", "f", "", "upload file name")
f.BoolVarP(&help, "help", "h", true, "帮助")
RootCmd.AddCommand(UploadCmd)
}
- Command
- 由于我们只实现了阿里云的 OSS 所以在 switch的时候只接受 aliyun的 flag。
- 读者可自行实现其他提供商,以及更精细化到按 endpoint 划分
var UploadCmd = &cobra.Command{
Use: "upload",
Long: "upload 文件上传",
Short: "upload 文件上传",
Example: "upload -f filename",
RunE: func(cmd *cobra.Command, args []string) error {
if help{
return nil
}
var (
uploader store.Uploader
err error
)
switch ossProvier {
case "aliyun":
uploader, err = aliyun.NewAliOssStore(&aliyun.Options{
Endpoint: ossEndpoint,
AccessKey: accessKey,
AccessSecret: accessSecret,
})
default:
return fmt.Errorf("该服务商不存在")
}
if err != nil {
return err
}
return uploader.Upload(bucketName, uploadFile, uploadFile)
},
}
我们看下当前cli
到此我们基本实现了之前简单版本的功能, 但是扩展性要远远大于之前的简单版本
但是现在还存在如下2个问题:
- access key 这种敏感数据 直接通过参数传入有安全风险, 需要改进
- 我们的上传需要补充进度条
改进一: 敏感信息用户输入
简单的做法是直接使用 fmt 的 Scan 函数从标准输出获取用户输入:
func getAccessFromInput() {
fmt.Print("请输入您的AccessKey:")
fmt.Scanf("%s", accessKey)
fmt.Print("请输入您的AccessSecret:")
fmt.Scanf("%s", accessSecret)
}
然后在uploader初始化的时候从终端读取:
func init(){
f := UploadCmd.PersistentFlags()
f.StringVarP(&ossProvier, "provider", "p", "aliyun", "oss供应商(现仅支持阿里云)")
f.StringVarP(&ossEndpoint, "endpoint", "e", "oss-cn-chengdu.aliyuncs.com", "oss 供应商 endpoint")
f.StringVarP(&bucketName, "bucket_name", "b", "liufudan-cloud-station", "oss 供应商 bucket name")
getAccessFromInput()
f.StringVarP(&uploadFile, "upload_file", "f", "", "upload file name")
f.BoolVarP(&help, "help", "h", false, "帮助")
RootCmd.AddCommand(UploadCmd)
}
为了防止密码被别人窥见到, 我们可以使用一个第三方库来加密我们的输入: GitHub - go-survey/survey
func getAccessFromInputV2() {
prompt1 := &survey.Password{
Message: "请输入您的AccessKey:",
}
survey.AskOne(prompt1, accessKey)
fmt.Println()
prompt2 := &survey.Password{
Message: "请输入您的AccessSecret:",
}
survey.AskOne(prompt2, accessSecret)
}
这样 AccessKey 和 AccessSecret 在终端输入的时候就相对安全了, 当然这个库还有很多不错的功能,对做漂亮的CLI交互来说,还是很不错的,有助于提升你工具的使用体验
改进二: 添加进度条
要现在做进度条,我们需要在上传的时候,获取当前上传进度,比如当前发送了多少个 bytes 的数据, 然后根据当前文件的大小 就可以计算出 当前的一个上传进度.
分析sdk是否可以获取上传中的进度相关信息
我们得先看sdk有没有给我们留口子
func (bucket Bucket) PutObjectFromFile(objectKey, filePath string, options ...Option) error
我们搜索支持的Option 可以找到Progress的选项
// Progress set progress listener
func Progress(listener ProgressListener) Option {
return addArg(progressListener, listener)
}
通过查看 ProgressListener 的定义,我们可以知道,我们可以提供一个lister在上传文件的时候, 他会把上传过程中的事件给我们
// ProgressListener listens progress change
type ProgressListener interface {
ProgressChanged(event *ProgressEvent)
}
我们可以看看能通过这个事件获取到那些信息: 开始, 传输中, 传输完成, 传输失败, 总共需要上传的大小(TotalBytes), 这次event上传成功了多少数据(RwBytes)
// ProgressEventType defines transfer progress event type
type ProgressEventType int
const (
// TransferStartedEvent transfer started, set TotalBytes
TransferStartedEvent ProgressEventType = 1 + iota
// TransferDataEvent transfer data, set ConsumedBytes anmd TotalBytes
TransferDataEvent
// TransferCompletedEvent transfer completed
TransferCompletedEvent
// TransferFailedEvent transfer encounters an error
TransferFailedEvent
)
// ProgressEvent defines progress event
type ProgressEvent struct {
ConsumedBytes int64
TotalBytes int64
RwBytes int64
EventType ProgressEventType
}
实现一个简易版的listner
有了这些数据,基本就够我们展示进度条时使用了. 接下来我们需要实现一个自己的 lister, 用户接收事件, 先简单打印下: aliyun/listener
import (
"fmt"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
)
// NewOssProgressListener todo
func NewOssProgressListener() *OssProgressListener {
return &OssProgressListener{}
}
// OssProgressListener is the progress listener
type OssProgressListener struct {
}
// ProgressChanged todo
func (p *OssProgressListener) ProgressChanged(event *oss.ProgressEvent) {
fmt.Println(event.EventType, event.TotalBytes, event.RwBytes)
}
然后我们把listner作为uploader的一个实例属性, 实例初始化时直接生成, 在上传的时候传递过去
// 构造函数
func NewAliOssStore(opts *Options) (*AliOssStore, error){
...
return &AliOssStore{
client: c,
listener:NewDefaultProgressListener(),
}, nil
}
type aliyun struct {
...
listner oss.ProgressListener
}
func (p *aliyun) UploadFile(bucketName, objectKey, localFilePath string) error {
...
err = bucket.PutObjectFromFile(objectKey, localFilePath, oss.Progress(p.listner))
...
}
找个进度条ui展示出来
比如我们要实现一个这样的进度条, 大家觉得容易不
[==> ] 30%
还是要费点功夫的,核心逻辑是: 通过退格键(\b)删除后重新渲染,视觉上看起来好像中间的部分在挪动一样
这里我们选择一个第三方库:progressbar 我们看下他样例
bar := progressbar.Default(100)
for i := 0; i < 100; i++ {
bar.Add(1)
time.Sleep(40 * time.Millisecond)
}
可以看到核心是通过Add来控制进度, 因此我们把需要上传的文件总大小当做total, 然后把每次上传了多个byte Add给bar就可以了
func (p *ProgressListener) ProgressChanged(event *oss.ProgressEvent) {
switch event.EventType {
case oss.TransferStartedEvent:
p.bar = progressbar.DefaultBytes(
event.TotalBytes,
"文件上传中",
)
case oss.TransferDataEvent:
p.bar.Add64(event.RwBytes)
case oss.TransferCompletedEvent:
fmt.Printf("\n上传完成\n")
case oss.TransferFailedEvent:
fmt.Printf("\n上传失败\n")
default:
}
}
我们测试下,看下效果:
当然你也可以优化下显示, 直接控制下显示的样式,比如
const (
bu = 1 << 10
kb = 1 << 20
mb = 1 << 30
gb = 1 << 40
tb = 1 << 50
eb = 1 << 60
)
// HumanBytesLoaded 单位转换
func HumanBytesLoaded(bytesLength int64) string {
if bytesLength < bu {
return fmt.Sprintf("%dB", bytesLength)
} else if bytesLength < kb {
return fmt.Sprintf("%.2fKB", float64(bytesLength)/float64(bu))
} else if bytesLength < mb {
return fmt.Sprintf("%.2fMB", float64(bytesLength)/float64(kb))
} else if bytesLength < gb {
return fmt.Sprintf("%.2fGB", float64(bytesLength)/float64(mb))
} else if bytesLength < tb {
return fmt.Sprintf("%.2fTB", float64(bytesLength)/float64(gb))
} else {
return fmt.Sprintf("%.2fEB", float64(bytesLength)/float64(tb))
}
}
// ProgressChanged todo
func (p *OssProgressListener) ProgressChanged(event *oss.ProgressEvent) {
switch event.EventType {
case oss.TransferStartedEvent:
p.bar = progressbar.NewOptions64(event.TotalBytes,
// ansi "github.com/k0kubun/go-ansi"
// 引入第三方库 修复windows终端输出换行问题
progressbar.OptionSetWriter(ansi.NewAnsiStdout()),
progressbar.OptionEnableColorCodes(true),
progressbar.OptionShowBytes(true),
progressbar.OptionSetWidth(30),
progressbar.OptionSetDescription("开始上传:"),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "=",
SaucerHead: ">",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
)
p.startAt = time.Now()
fmt.Printf("文件大小: %s\n", HumanBytesLoaded(event.TotalBytes))
case oss.TransferDataEvent:
p.bar.Add64(event.RwBytes)
case oss.TransferCompletedEvent:
fmt.Printf("\n上传完成: 耗时%d秒\n", int(time.Since(p.startAt).Seconds()))
case oss.TransferFailedEvent:
fmt.Printf("\n上传失败: \n")
default:
}
}
最后显示效果如下:
文件大小: 1.87KB
开始上传: 100% [==============================] (81.346 kB/s)
上传完成: 耗时0秒
...
总结
虽然我们没有实现API Server,但是我们把核心上传逻辑使用接口进行了解耦,也方便我们添加其他云的插件, 为后面扩展留下了良好的设计。虽然实现的功能大体相近,但是思维模式完全不一样, 这就是运维写出来的代码虽然能用,但那味就是有点不对的根本原因之一。
- 面向对象思维模式
- 合理组织你的项目结构
- 如何使用接口解耦程序
- 测试驱动开发TDD
- 断点调试(debug)
- CLI
- 合理使用第三方库