26

写一个 Golang cli 程序吧 | Tonny’s blog

 4 years ago
source link: https://tonnyl.io/Write-A-Go-App/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

写一个 Golang cli 程序吧

本文会介绍如何编写一个 go 命令行(cli)程序, 用于浏览 GitHub trending repositories 和 developers.
首先我们看一下最终的实现效果:

% wukong repo -lang go -period daily

+------+----------------------------+--------------------------+----------+---------------------+-------------------------------------------------------------------+
| RANK |            NAME            |       DESCRIPTION        | LANGUAGE | STARS(TOTAL/PERIOD) |                                URL                                |
+------+----------------------------+--------------------------+----------+---------------------+-------------------------------------------------------------------+
|    1 | OpenDiablo2                | An open source re-implem | Go       | 2626/1625           | https://github.com/OpenDiablo2/OpenDiablo2                        |
|      |                            | entation of Diablo 2     |          |                     |                                                                   |
+------+----------------------------+--------------------------+----------+---------------------+-------------------------------------------------------------------+
|    2 | validator                  | Go Struct and Field    | Go       | 4154/149            | https://github.com/go-playground/validator                        |
|      |                            | validation, including Cr |          |                     |                                                                   |
|      |                            | oss Field, Cross Struct, |          |                     |                                                                   |
|      |                            |  Map, Slice and Array di |          |                     |                                                                   |
|      |                            | ving                     |          |                     |                                                                   |
+------+----------------------------+--------------------------+----------+---------------------+-------------------------------------------------------------------+
|    4 | grpc-go                    | The Go language implemen | Go       | 10019/58            | https://github.com/grpc/grpc-go                                   |
|      |                            | tation of gRPC. HTTP/2 b |          |                     |                                                                   |
|      |                            | ased RPC                 |          |                     |                                                                   |
+------+----------------------------+--------------------------+----------+---------------------+-------------------------------------------------------------------+
|    . | ...                        | ...                      | ...      |  ...                | ...                                                               |
+------+----------------------------+--------------------------+----------+---------------------+-------------------------------------------------------------------+
% wukong dev

+------+------------------------------+--------------------------+----------------------------------+
| RANK |             NAME             |  REPO NAME/DESCRIPTION   |               URL                |
+------+------------------------------+--------------------------+----------------------------------+
|    1 | Alon Zakai(kripken)          | sql.js - SQLite compiled | https://github.com/kripken       |
|      |                              |  to JavaScript through E |                                  |
|      |                              | mscripten                |                                  |
+------+------------------------------+--------------------------+----------------------------------+
|    2 | Klaus Post(klauspost)        | compress - Optimized com | https://github.com/klauspost     |
|      |                              | pression packages        |                                  |
+------+------------------------------+--------------------------+----------------------------------+
|    3 | siddontang(siddontang)       | ledisdb - a high perform | https://github.com/siddontang    |
|      |                              | ance NoSQL powered by Go |                                  |
+------+------------------------------+--------------------------+----------------------------------+
|    . | ...                          | ...                      | ...                              |
+------+------------------------------+--------------------------+----------------------------------+

GitHub 官方并没有提供 trending 相关的 API, 不过 https://github.com/huchenme/github-trending-api 提供了一个非官方的实现, 我们可以通过它实现我们的需求.

github-trending-api 提供了3个 API:

  • https://github-trending-api.now.sh/repositories?language=javascript&since=weekly: 用于查询 trending repositories, 其中 language 为可选参数, 默认为 空字符串(“”), 所有侯选值可以通过第三个 API 获取, since 也为可选参数, 默认为 daily, 候选值为 daily, weekly, monthly;
  • https://github-trending-api.now.sh/developers?language=javascript&since=weekly: 用于查询 trending developers, 参数类型和含义上一个 API 类似;
  • https://github-trending-api.now.sh/languages: 获取上面的接口中 language 参数的所有候选值.

在调用第一个和第二个 API 时, 我们都要调用到第三个 API, 因为我们需要验证 language 参数的合法性, 这可能会给 github-trending-api 造成很多不必要的请求而增加原作者的服务器的压力, 并且理论上说 languages 数据的变化不会很频繁, 所以我们直接把 languages 数据直接放在 GitHub 上, 每次请求从 GitHub 的服务器请求即可. 最终第三个 API 的 URL 就被我们替换为了 https://raw.githubusercontent.com/TonnyL/Wukong/master/resources/languages.json .

data struct

https://github-trending-api.now.sh/repositories?language=javascript&since=weekly 这个接口为例, 返回的 JSON 格式的数据为:

[
  ...
  {
    "author": "google",
    "name": "gvisor",
    "avatar": "https://github.com/google.png",
    "url": "https://github.com/google/gvisor",
    "description": "Container Runtime Sandbox",
    "language": "Go",
    "languageColor": "#3572A5",
    "stars": 3320,
    "forks": 118,
    "currentPeriodStars": 1624,
    "builtBy": [
      {
        "href": "https://github.com/viatsko",
        "avatar": "https://avatars0.githubusercontent.com/u/376065",
        "username": "viatsko"
      }
    ]
  }
  ...
]

对应的 data struct 即为:

type Repository struct {
    // google
    Author string `json:"author"`
    // gvisor
    Name string `json:"name"`
    // https://github.com/google.png
    Avatar string `json:"avatar"`
    // https://github.com/google/gvisor
    Url string `json:"url"`
    // Container Runtime Sandbox
    Description string `json:"description"`
    // Go
    Language string `json:"language"`
    // #3572A5
    LanguageColor string `json:"languageColor"`
    // 3320
    Stars int32 `json:"stars"`
    // 118
    Forks int32 `json:"forks"`
    // 1624
    CurrentPeriodStars int32     `json:"currentPeriodStars"`
    BuiltBy            []BuiltBy `json:"builtBy"`
}

比较简单, 这里不再解释了.

request

发起一个 get 类型的 http 请求即可. 以获取 trending repositories 为例:

// Receive an array of trending repositories.
func FetchTrendingRepositories(language, since string) ([]Repository, error) {
    resp, err := http.Get(fmt.Sprintf("https://github-trending-api.now.sh/repositories?language=%s&since=%s", language, since))
    if err != nil {
        return nil, err
    }

    defer resp.Body.Close()

    repositories := make([]Repository, 0)
    jsonErr := json.NewDecoder(resp.Body).Decode(&repositories)
    if jsonErr != nil {
        return nil, jsonErr
    }

    return repositories, nil
}

table writer

获取到数据之后, 要这样展示给用户呢? https://github.com/olekukonko/tablewriter , 用 ASCII 表格的形式. tablewriter 提供了丰富的功能, 我们以展示 trending repositories 为例:

func ShowTableOfRepositories(repos []Repository) {
    table := tablewriter.NewWriter(os.Stdout)
    table.SetHeader([]string{"Rank", "Name", "Description", "Language", "Stars(Total/Period)", "Url"})
    table.SetRowLine(true)

    for index, repo := range repos {
        table.Append([]string{strconv.Itoa(index + 1), repo.Name, repo.DisplayDescription(), repo.DisplayLanguage(), fmt.Sprintf("%d/%d", repo.Stars, repo.CurrentPeriodStars), repo.Url})
    }

    table.Render()
}

table.SetHeader([]string{"Rank", "Name", "Description", "Language", "Stars(Total/Period)", "Url"}) 设置表格的头, table.SetRowLine(true) 设置显示单元格的边界线, 然后遍历一下 slice 中的数据, 每一条数据作为一行展示 table.Append([]string{strconv.Itoa(index + 1), repo.Name, repo.DisplayDescription(), repo.DisplayLanguage(), fmt.Sprintf("%d/%d", repo.Stars, repo.CurrentPeriodStars), repo.Url}) .

go 官方提供了一个名为 flag 的包, 方便进行命令行解析. 不过我们使用的是 https://github.com/urfave/cli 这个第三方库, 因为使用起来比较简单, 功能也比较丰富. 还有一个比较出名的 cobra, 功能更多, 很强大, 相应地使用起来也更复杂. 我们使用 cli 即可.

func main() {
    var lang string
    var period string

    flags := []cli.Flag{
        cli.StringFlag{
            Name:        "lang,l",
            Usage:       "language, default to `all`, use list command to see all the available options",
            Required:    false,
            Destination: &lang,
        },
        cli.StringFlag{
            Name:        "period,p",
            Usage:       "Period, default to `daily`, possible values: daily, weekly and monthly",
            Required:    false,
            Destination: &period,
        },
    }

    app := cli.NewApp()
    app.Name = "Wukong" // Incredible name!
    app.Usage = "A command-line tool for browsing GitHub trending repositories&developers written by Go."
    app.Version = "0.1.0-alpha02"
    app.Copyright = "Wukong is under an MIT license. See the [LICENSE](https://github.com/TonnyL/Wukong/blob/master/LICENSE) for more information."
    app.Commands = []cli.Command{
        {
            Name:        "repo",
            Aliases:     []string{"r", "repositories", "repository"},
            Description: "See the developers that the GitHub community is most excited about.",
            Usage:       "-lang x -period y",
            Flags:       flags,
            Action: func(c *cli.Context) error {
                params := CheckParams(lang, period)
                if params == nil {
                    return errors.New(" Invalid params")
                }

                repos, err := FetchTrendingRepositories(params.Lang, params.Period)
                if err != nil {
                    return err
                }

                ShowTableOfRepositories(repos)

                return nil
            },
        },
        {
            Name:        "dev",
            Aliases:     []string{"d", "developers", "developer"},
            Description: "See the repositories that the GitHub community is most excited about.",
            Usage:       "-lang x -period y",
            Flags:       flags,
            Action: func(c *cli.Context) error {
                params := CheckParams(lang, period)
                if params == nil {
                    return errors.New(" Invalid params")
                }

                devs, err := FetchTrendingDevelopers(params.Lang, params.Period)
                if err != nil {
                    return err
                }

                ShowTableOfDevelopers(devs)

                return nil
            },
        },
        {
            Name:    "lang",
            Aliases: []string{"l", "languages", "language"},
            Usage:   "List all the available language options",
            Action: func(c *cli.Context) error {
                langs, err := FetchLanguages()
                if err != nil {
                    return err
                }

                ShowTableOfLanguages(langs)

                return nil
            },
        },
    }
    err := app.Run(os.Args)
    if err != nil {
        fmt.Print(" command error: " + err.Error())
    }
}
  • flags 即为用户可以输入的参数, 我们定义了 langperiod 两个 string 类型变量并绑定对应的 flag;
  • app.Name 指定我们的 cli 应用的名称;
  • app.Usage 描述我们的应用的用途是什么;
  • app.Version 描述我们的应用的版本;
  • app.Copyright 描述我们的应用的版权信息;
  • app.Commands 为关键部分, 定义了三个可以执行的命令: repo, devlang . repo 列出 trending repositories, dev 列出 trending developers, lang 列出所有支持的编程语言.
    • Name: 命令的名称;
    • Aliases: 命令的别名, 一个命令可以有多个别名;
    • Description: 命令的描述信息;
    • Usage: 命令的用法;
    • Flags: 命令接收的参数;
    • Action: 命令如何执行.

我们还需要验证一下用户输入参数的合法性, 实现默认参数以及减少不必要的请求.

func CheckParams(lang, period string) *Params {
    if period == "" {
        period = "daily"
    }

    if period != "daily" && period != "weekly" && period != "monthly" {
        return nil
    }

    if lang == "" || lang == "all" {
        return &Params{
            Lang:   "",
            Period: period,
        }
    } else {
        languages, err := FetchLanguages()
        if err != nil {
            return nil
        }

        for _, l := range languages {
            if l.UrlParam == lang {
                return &Params{
                    Lang:   lang,
                    Period: period,
                }
            }
        }
    }

    return nil
}

编译为可执行程序

go run 可以让编写的 go 程序解释执行. 在对应目录执行 go run 即可:

go run data.go main.go

go build 可以编译成对应平台的可执行程序. 在对应目录执行 go build:

go build -o wukong data.go main.go

执行完毕后, 会在当前目录下生成一个名为 wukong 的可执行文件, 双击该文件即可执行.

上面的命令只会生成当前系统对应的可执行文件, 那如果我在 macOS 上, 想要生成 Linux 平台的可执行文件呢? 这个时候就要用到交叉编译了.
在 macOS 或 Linux 上编译其他平台的二进制可执行文件:

# macOS
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build ...
# Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ...
# Windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build ...

在 Windows 上编译其他平台的二进制可执行文件:

# macOS
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build server.go
# Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build server.go
# Windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build server.go

其中,参数 GOOS, GOARCH, CGO 参数的含义分别是:

  • GOOS: 目标平台的操作系统(darwin, freebsd, linux, windows 等)
  • GOARCH: 目标平台的 CPU 体系架构(386, amd64, arm 等. 使用 go tool dist list 查看支持的 OS 和架构列表.)
  • CGO_ENABLED: 设置为0表示不启用CGO进行编译, 代码中如果有用到C语言相关的库,编译时就需要开启 CGO_ENABLED=1

压缩可执行文件

首先我们看一下 go build 生成的可执行文件的大小:

% ls -lh

total 20200
-rw-r--r--  1 tonnyl  staff   2.5K Nov 19 00:50 data.go
-rw-r--r--  1 tonnyl  staff   1.8K Nov 17 18:05 data_test.go
-rw-r--r--  1 tonnyl  staff   5.4K Nov 19 01:19 main.go
-rw-r--r--  1 tonnyl  staff   1.5K Nov 17 18:05 main_test.go
-rwxr-xr-x@ 1 tonnyl  staff   9.8M Nov 17 20:53 wukong

9.8M, 你没有看错. 事实上, 就算是最简单的 hello world 变异后也有 2M 之巨. 我们用到了一些第三方库, 体积就来到了近 10M 之多.
相比较而言, C 语言版的 hello world 编译出来的可执行文件仅是 KB 级别.

为什么体积会这么大呢? 这里是官方的回答: https://golang.org/doc/faq#Why_is_my_trivial_program_such_a_large_binary . 简单来说就是: go 使用的是静态编译, 打包出来的可执行文件包含了 go 运行时, 而 C 是以动态链接库的形式编译的. 更多关于 go 和 C 语言编译的差异, 可以访问 https://www.cnxct.com/why-golang-elf-binary-file-is-large-than-c/ .

如果减小生成的可执行文件的体积呢?

1. 设置编译参数 -ldflags:

设置编译参数 -ldflags "-w -s", 其中 -w 去掉调试信息(无法使用gdb调试), -s 去掉符号信息.

% go build -o wukong -ldflags "-s -w" main.go data.go
% ls -lh                                             
total 16176
-rw-r--r--  1 tonnyl  staff   2.5K Nov 19 00:50 data.go
-rw-r--r--  1 tonnyl  staff   1.8K Nov 17 18:05 data_test.go
-rw-r--r--  1 tonnyl  staff   5.4K Nov 19 01:19 main.go
-rw-r--r--  1 tonnyl  staff   1.5K Nov 17 18:05 main_test.go
-rwxr-xr-x  1 tonnyl  staff   7.9M Nov 17 21:57 wukong 

体积来到了 7.9M, 减少了约 20%.

2. 使用 UPX 压缩

UPX 是一款用于压缩可执行程序文件的压缩器. 以下以 macOS 为例(其他平台也支持, 仅安装和部分使用命令不同):

% brew install upx
% upx wukong
% ls -lh    
total 7672
-rw-r--r--  1 tonnyl  staff   2.5K Nov 19 00:50 data.go
-rw-r--r--  1 tonnyl  staff   1.8K Nov 17 18:05 data_test.go
-rw-r--r--  1 tonnyl  staff   5.4K Nov 19 01:19 main.go
-rw-r--r--  1 tonnyl  staff   1.5K Nov 17 18:05 main_test.go
-rwxr-xr-x  1 tonnyl  staff   3.0M Nov 17 21:57 wukong 

体积来到了 3M, 相比最初的 9.8M 减少了近 70%, 效果显著.

发布到 Homebrew

Homebrew 是一个流行的软件包管理器, 支持了 macOS 和 Linux, 得益于 Windows Subsystem for Linux (WSL), Homebrew 也间接支持了 Windows.
brew 有啤酒🍺的意思, homebrew 自然就是「家酿」了, 这有助于我们理解 Homebrew 的一些概念:

  • Keg(酒桶): 用户安装好的脚本, 软件等;
  • Cellar(酒窖): 所有用 Homebrew 安装在本地的脚本或软件组成的集合;
  • Formula(配方): 定义如何下载, 编译和安装脚本或软件的 Ruby 脚本;
  • Tap: 软件仓库.

当我们输入 brew install go, 发生了什么呢? Homebrew 首先会在 Tap 中寻找 go 对应的 Ruby 脚本(Tap 中存放的并不是编译好的二进制文件, 而是 Formula), 然后按照脚本中定义的方法将 Keg 下载, 编译, 安装到 Cellar 中.

Homebrew 提供了两个名为 homebrew/homebrew-corehomebrew/homebrew-cask 的官方 Tap, homebrew-core 存放了一些流行的 Formula, 如果你的应用满足了这些需求之后, 也可以向官方申请以收录到 homebrew-core 中; 而 homebrew-cask 是 Homebrew 的一个扩展, 用于安装 macOS 原生应用, 所以不在我们的讨论范围.

了解了 Homebrew 的工作流程之后, 想要让我们的应用支持 Homebrew 安装, 大致流程为:

  1. 创建我们自己的 Tap(Git 仓库), 用于存放 Formula;
  2. 创建 Formula, 用 Ruby 脚本描述我们的应用如果被下载, 安装等.

创建 Tap

Tap 其实是一个 Git 仓库, 所以我们直接在 GitHub 上创建一个仓库即可. 但是仓库名有一定的要求 — 必须是 homebrew-foobar 的形式. 例如我的 Tap 仓库的名字为 homebrew-tap .

在本地 homebrew-tap 仓库根目录执行以下命令让 Homebrew 可以跟踪我们的 Tap:

% brew tap Your-GitHub-Username/taps
% brew tap github.com/Your-GitHub-Username/homebrew-taps

创建 Formula

生成二进制可执行文件压缩包的哈希文件, 后面会用到.

% tar -czf wukong.tar.gz wukong
% shasum -a 256 wukong.tar.gz > wukong.tar.gz.sha256

我们首先把 wukong 的源码发布一个 release, 并且附上之前生成的二进制可执行文件压缩包和其对应的哈希文件. release 发布成功后, 复制 wukong-macos.tar.gz 的链接地址. 然后执行命令:

% brew create https://github.com/TonnyL/Wukong/releases/download/v0.1.0-alpha02/wukong-macos.tar.gz

Homebrew 会在 /usr/local/Library/Formula/ 目录下创建一个名为 filename.rb 的文件, 其文件名取决于上面链接地址指向的文件的名称. 复制这个文件到本地 homebrew-tap 仓库中 Formula 目录下, 更名为 wukong.rb 并打开:

class Wukong < Formula
  desc "A command-line tool for browsing GitHub trending repositories and developers written by Go."
  homepage "https://github.com/TonnyL/Wukong/"
  version "v0.1.0-alpha02"

  bottle :unneeded

  if OS.mac?
    url "https://github.com/TonnyL/Wukong/releases/download/v0.1.0-alpha02/wukong-macos.tar.gz"
    sha256 "446d2cd7f185778b020ae55f721c7b2edb4bf4b919706733ab8f3d3d9188e523"
  elsif OS.linux?
    url "https://github.com/TonnyL/Wukong/releases/download/v0.1.0-alpha02/wukong-linux.tar.gz"
    sha256 "cc4df15ab5e3cf757ae9aa79c2d9d6793947cf4a27cc219ca6c1c6f0c9d6c2b9"
  end

  def install
    bin.install "wukong"
  end

  test do
     system "#{bin}/wukong", "--help"
  end
end

desc 即简介, description; homepage 即主页地址; version 即应用版本; url 和 sha256 即二进制可执行文件的地址及其哈希值. 其中 url 和 sha256 会根据 OS 的不同赋予不同的值.
默认情况下, Homebrew 会编译源代码, 但是我们现在讨论的是预编译版本, 所以不用考虑编译过程, 直接调用 Homebrew 提供的 bin.install 即可将预编译好的二进制文件安装到 /usr/local/bin/.
有时候安装成功之后, 功能不一定完备. 我们也可以写测试用例验证功能是否完整. 这里我们简单输出软件版本, 不做深入讨论.
Bottle 也不做讨论.

保存之后我们即可开始安装:

% brew install TonnyL/tap/Wukong
==> Installing wukong from tonnyl/tap
==> Downloading https://github.com/TonnyL/Wukong/releases/download/v0.1.0-alpha0
Already downloaded: /Users/tonnyl/Library/Caches/Homebrew/downloads/f97e0d8bde2c8dcaf77d5900cd6445b33069f91e51fb59d3f7aa07fdd264b758--wukong-macos.tar.gz
  /usr/local/Cellar/wukong/v0.1.0-alpha02: 3 files, 3.0MB, built in 6 seconds 

本文源代码: https://github.com/TonnyL/Wukong


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK