对于添加更多的命令,使用 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)
}
}
它的原理非常简单,定义一个结构体,保存所有要变更的字段信息,再新增一个字段定义变更类型,例如源码中的注释,后台根据这个字段类型,去修改对应的内容。
文章命令行工具开发部分基本就完成了,后续有新的功能或者优化再开新的文章。
扫码体验: