对于添加更多的命令,使用 flag,就有点麻烦了,这次我们使用一个更高级的库 cobra。同时,我们使用 viper 替换 ini 库,这个库可以读取多种格式的配置文件,可以读取环境变量。

要实现的功能如下:

  • 上传文章
  • 文章删除
  • 更新文章标题
  • 更新文章关键字
  • 更新文章内容
  • 文章公开开关
  • 文章加锁开关
  • 根据标题查找文章
  • 根据关键字查询文章

现在使用 cobra 实现上面的命令。

首先,我想要的效果如下:

  • 上传文章 gart upload title keyword filename ispub islock
  • 删除文章 gart remove uuid
  • 更新文章标题 gart updatetitle uuid title
  • 更新文章关键字 gart updatekeyword uuid keyword
  • 更新文章内容 gart updatecontent uuid filename
  • 更新文章公开或不公开 gart updatepub uuid ispub
  • 更新文章加锁或不加锁 gart updatelock uuid islock
  • 根据标题或关键字查找文章 gart search content

上面的 upload,remove,updatetitle,updatekeyword 等,在 cobra 中都是命令。title keyword 等都是参数。

我在读 cobra 文档后,最疑惑的地方就是标志和参数,这两者最大的区别就是标志需要在命令中添加–flag 然后是值,而参数是直接跟在命令后,直接就是内容的。标志可以更改程序的行为。

我在这个程序中,配置了标志 config,就是获取配置文件。当用户在命令行中配置–config xxx.file 就从命令中读取配置文件,否则就在用户的 HOME 目录查找配置文件。获取 icode 和 isecret。

详细阅读 cobra 文档后,开始撸代码。参考样例文档,我们也先改造 go 入口文件 main.go。

package main

import "gart/cmd"

func main() {
	cmd.Execute()
}

就是这样的简单,只有一个执行函数。它会加载 cmd 文件夹下的 root 命令。

cmd 文件夹组织如下:

cmd
  root.go
  upload.go
  remove.go
  ...

我们看下 root.go,这个文件还是很重要的。


var (
	cfgFile string
	token   string
)

func init() {
	cobra.OnInitialize(initConfig)
	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/conf.toml)")
}

func initConfig() {
	if cfgFile != "" {
		viper.SetConfigFile(cfgFile)
	} else {
		home, err := homedir.Dir()
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
		viper.AddConfigPath(home)
		viper.AddConfigPath(".")
		viper.SetConfigName("conf")
		viper.SetConfigType("toml")
	}

	if err := viper.ReadInConfig(); err != nil {
		fmt.Println("Cant't read config:", err)
		os.Exit(1)
	}
	// 每次启动都调用token
	getToken()

}

// 根命令
var rootCmd = &cobra.Command{
	Use:   "gart",
	Short: "gart 是文章管理命令行工具。",
	Long:  `gart 是文章管理命令行工具,主要用来管理豆子碎片小程序中的文章。`,
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("该命令行工具用来管理豆子碎片小程序的文章。")
	},
}
// main函数中调用的该函数
func Execute() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

首先,需要定义根命令 rootCmd,然后实现 Execute()函数。

在初始化 init()函数中,定义了 cobra 初始化回调函数,进行了全局标志的配置以及配置文件的加载。

我这里还添加了 token 的获取,这个花费了我一些时间,琢磨它的调用顺序。它的调用顺序如下:
init()->initConfig()->getToken()

这之后,token 的变量就是有效的。在其它的命令中就可以直接使用。

getToken 函数定义如下:


func getToken() {
	var err error
	icode := viper.GetString("icode")
	isecret := viper.GetString("isecret")
	expire := viper.GetString("expire_at")
	expireAt := utils.Str2Int64(expire)
	now := time.Now().Unix()
	if now > expireAt {
		token, err = service.GetToken(icode, isecret)
		if err != nil {
			fmt.Println("获取token错误,", err)
			os.Exit(1)
		}
		expireAtStr := strconv.FormatInt(now+7000, 10)
		viper.Set("expire_at", expireAtStr)
		viper.Set("token", token)
		err = viper.WriteConfig()
		if err != nil {
			fmt.Println("写入配置文件错误,", err)
			os.Exit(1)
		}
	} else {
		token = viper.GetString("token")
	}
}

在 Viper 这回写到文件中,遇到了点问题,刚开始获取 token 后,无法写入到配置文件中。参考文档,已经设置了新的有效时间和 token。就是这两句代码:

viper.Set("expire_at", expireAtStr)
viper.Set("token", token)

排查后,发现没有调用下面的函数原因:

err = viper.WriteConfig()
if err != nil {
			fmt.Println("写入配置文件错误,", err)
			os.Exit(1)
		}

经历一番挫折后,该库的简单使用已掌握,接下来先完成上传文章。

定义上传文章的 command,并将 command 添加到 Root 中。

func init() {
	rootCmd.AddCommand(upCmd)
}

var upCmd = &cobra.Command{
	Use:   "upload",
	Short: "上传文章,可在豆子碎片小程序中查看。",
	Long: `上传文章,可在豆子碎片小程序中查看。参数依次为题目,关键字,Markdown文件,是否公开,是否加锁。
	参数题目,关键字,Markdown文件必填,是否公开,是否加锁选填,默认为否。
	例如:gart upload 上传示例1 命令行,工具 ./example.md
	`,
	Args: cobra.RangeArgs(3, 5),
	Run: func(cmd *cobra.Command, args []string) {
		var (
			title    string
			keyword  string
			filename string
			ispub    int
			islock   int
		)
		title = args[0]
		keyword = args[1]
		filename = args[2]
		l := len(args)
		switch l {
		case 4:
			ispub = utils.Str2Int(args[3])
		case 5:
			ispub = utils.Str2Int(args[3])
			islock = utils.Str2Int(args[4])
		}
		fmt.Println("上传参数如下:")
		var isPubStr string = "否"
		var isLockStr string = "否"
		if ispub == 1 {
			isPubStr = "是"
		}
		if islock == 1 {
			isLockStr = "是"
		}
		t := table.NewWriter()
		t.SetOutputMirror(os.Stdout)
		t.AppendHeader(table.Row{"题目", "关键字", "文件名称", "是否公开", "是否加锁"})
		t.AppendRows([]table.Row{
			{title, keyword, filename, isPubStr, isLockStr},
		})
		t.AppendSeparator()
		t.Render()
		err := service.UploadArt(token, title, keyword, filename, ispub, islock)
		if err != nil {
			fmt.Println("上传发生错误,", err)
		} else {
			fmt.Println("上传成功")
		}
	},
}

在上面的实现中,使用了 cobra 参数约束函数,Args: cobra.RangeArgs(3, 5),,保证参数最少 3 个,最多不能超过 5 个。这完全满足我的需求。

在打印列表时,使用 go-pretty 库的 table 组件,可以美化控制台输出,我这里以表格的形式显示上传的参数,一目了然。

剩下的就简单了,调用后台上传接口,返回结果然后展示就可以了。

当我们上传了文章之后,肯定希望能够查询我们上传的文章了,查询文章根据用户输入的内容,后台进行文章的标题和关键字进行匹配。然后返回查询到的数据。后台限制最多返回 20 条记录,这是为了服务器性能综合考虑的。如果用户查找的内容,不在返回的记录中,请输入更多内容,进行更精确的匹配。


func init() {
	rootCmd.AddCommand(qCmd)
}

var qCmd = &cobra.Command{
	Use:   "search",
	Short: "查找文章,最多返回20条记录。",
	Long:  `查找文章, 根据文章的标题和关键字匹配查询,最多返回20条记录。可以输入更多的内容进行精确查找。`,
	Args:  cobra.MinimumNArgs(1),
	Run: func(cmd *cobra.Command, args []string) {

		content := args[0]

		list, err := service.SearchArt(token, content)
		if err != nil {
			fmt.Println("查询发生错误,", err)
		} else {
			n := len(list)
			if n > 0 {
				var (
					cts    string
					uts    string
					ispub  string = "否"
					islock string = "否"
				)

				t := table.NewWriter()
				t.SetOutputMirror(os.Stdout)
				t.AppendHeader(table.Row{"UUID", "题目", "关键字", "是否公开", "是否加锁", "创建时间", "修改时间"})
				for _, v := range list {
					cts = utils.TS2Str(v.Createtime)
					uts = utils.TS2Str(v.Updatetime)
					if v.IsPub == 1 {
						ispub = "是"
					}
					if v.IsLock == 1 {
						islock = "是"
					}
					t.AppendRows([]table.Row{
						{v.Uuid, v.Title, v.Keyword, ispub, islock, cts, uts},
					})
					t.AppendSeparator()
				}

				t.Render()
			} else {
				fmt.Println("未找到记录,尝试更换内容试试。")
			}
		}

	},
}

代码的组织架构和上传的差不多。都需要先定义命令,然后添加到 root 命令中。这里的输出结果使用美化的 table 显示。当查询到内容后,就可以使用结果中的 UUID 进行文章的管理维护了。

有的时候,我们上传完文章之后,会发现我们的文章标题不合适,或想更好的描述文章内容。如果没有这个接口,我们需要删除文章,然后再重新上传。所以,我们实现这个功能,方便我们修改文章标题。

修改文章标题,我打算使用的命令格式如下:

gart title uuid newtitle

其中,紧挨着 gart 的 title 是命令,表示要更新文章标题,uuid 是文章的主键,可以查询识别是哪篇文章,newtitle 就是要修改的文章标题了。

实现的代码如下:


func init() {
	rootCmd.AddCommand(uptTitleCmd)
}

var uptTitleCmd = &cobra.Command{
	Use:   "title",
	Short: "更新文章标题,参数需要UUID,新的标题。",
	Long:  `更新文章标题,参数需要UUID,新的标题,需要先获取文章的UUID。`,
	Args:  cobra.MinimumNArgs(2),
	Run: func(cmd *cobra.Command, args []string) {
		uuid := args[0]
		title := args[1]
		uar := service.UpdateArtReq{
			Uuid:    uuid,
			Title:   title,
			UptType: 1,
		}
		err := service.UpdateArt(token, &uar)
		if err != nil {
			fmt.Println("更新标题发生错误,", err)
		} else {
			fmt.Println("更新成功")
		}
	},
}

实现比较简单,除了看命令行输出,还可以去小程序中查看实际结果。

如果想要更好的搜索结果,或者想要给文章分类,都可以添加关键字,关键字是搜索中的一个因子。例如,我的文章是描述的一个 go 教程,而标题中没有 go 的字眼,这是我在关键字中添加 go,就可以根据 go 搜索出这篇文章。关键字如此重要,所以我们要认真的定义,依据你的规则,你想要的效果来设计关键字。今天,我们实现这个功能,方便我们修改文章关键字。

修改文章关键字,我打算使用的命令格式如下:

gart keyword uuid newkeyword

其中,紧挨着 gart 的 keyword 是命令,表示要更新文章关键字,uuid 是文章的主键,可以查询识别是哪篇文章,newkeyword 就是要修改的文章关键字了,注意,这里是整个替换,所以你要加新的关键字,也把要保留的关键字加上去。

实现的代码如下:


func init() {
	rootCmd.AddCommand(uptKeywordCmd)
}

var uptKeywordCmd = &cobra.Command{
	Use:   "keyword",
	Short: "更新文章关键字,参数需要UUID,新的关键字。",
	Long:  `更新文章关键字,参数需要UUID,新的关键字,需要先获取文章的UUID。`,
	Args:  cobra.MinimumNArgs(2),
	Run: func(cmd *cobra.Command, args []string) {
		uuid := args[0]
		keyword := args[1]
		uar := service.UpdateArtReq{
			Uuid:    uuid,
			Keyword: keyword,
			UptType: 2,
		}
		err := service.UpdateArt(token, &uar)
		if err != nil {
			fmt.Println("更新关键字发生错误,", err)
		} else {
			fmt.Println("更新成功")
		}
	},
}

当我们写好了文章,并上传到服务器,我们在修改调整本地 Markdown 文件后,服务器上的文章并不会更新,所以,如果你想要将更新的内容反馈到服务器上时,你可以修改文章的内容。今天,我们实现这个功能,方便我们修改文章内容。

修改文章内容,我打算使用的命令格式如下:

gart content uuid newfilename

其中,紧挨着 gart 的 content 是命令,表示要更新文章内容,uuid 是文章的主键,可以查询识别是哪篇文章,newfilename 是修改内容后的文件名称。

实现的代码如下:


func init() {
	rootCmd.AddCommand(uptContentCmd)
}

var uptContentCmd = &cobra.Command{
	Use:   "content",
	Short: "更新文章内容,参数需要UUID,新的文件内容。",
	Long:  `更新文章内容,参数需要UUID,新的文件内容,需要先获取文章的UUID。`,
	Args:  cobra.MinimumNArgs(2),
	Run: func(cmd *cobra.Command, args []string) {
		uuid := args[0]
		filename := args[1]
		content, err := service.GetFileContent(filename)
		if err != nil {
			fmt.Println("读取文件内容发生错误,", err)
			return
		}
		uar := service.UpdateArtReq{
			Uuid:    uuid,
			Content: string(content),
			UptType: 3,
		}
		err = service.UpdateArt(token, &uar)
		if err != nil {
			fmt.Println("更新内容发生错误,", err)
		} else {
			fmt.Println("更新成功")
		}
	},
}

前面修改文章的几个命令,我们看到一个现象,它们共同调用了同一个函数 UpdateArt,我今天讲下,多个命令是如何做到调用同一个函数实现的?

先看下实现源码:


// 更新文章请求
type UpdateArtReq struct {
	Uuid    string `json:"uuid"`    // uuid
	Title   string `json:"title"`   // 题目
	Keyword string `json:"keyword"` // 关键字
	Content string `json:"content"` // 内容
	IsPub   int    `json:"ispub"`   // 是否公开
	IsLock  int    `json:"islock"`  // 是否加锁
	UptType int    `json:"utype"`   // 更新类型 1,题目 2,关键字 3,内容 4,是否公开 5,是否加锁
}

// 更新文章信息
func UpdateArt(token string, uar *UpdateArtReq) error {
	if token == "" {
		return errors.New("token不能为空")
	}

	url := fmt.Sprintf("%s/uptVArt?token=%s", OPEN_URL, token)

	reqBody := new(bytes.Buffer)

	json.NewEncoder(reqBody).Encode(uar)

	req, err := http.NewRequest("POST", url, reqBody)
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}
	var result RespMsg
	err = json.Unmarshal(data, &result)
	if err != nil {
		return err
	}

	if result.Code == 1 {
		return nil
	} else {
		return errors.New(result.Msg)
	}
}

它的原理非常简单,定义一个结构体,保存所有要变更的字段信息,再新增一个字段定义变更类型,例如源码中的注释,后台根据这个字段类型,去修改对应的内容。

文章命令行工具开发部分基本就完成了,后续有新的功能或者优化再开新的文章。

扫码体验:豆子碎片小程序