用 Go 寫個小工具:wu 的煉成

最近使用 Golang 編寫完成了一個命令行下的小工具: wu , 這個小工具的主要用途是監視文件改動并執行指定的命令。盡管有點重新發明輪子的嫌疑, 但是設計和實現它的過程中我還是有不少收獲的。

我很早就對 Golang 有興趣了,之前在沒有經過系統學習的情況下跌跌撞撞地完成了一個很小的應用, 并最終總結成 自己動手寫 Git HTTP Server 這篇文章, 之后又稍微研究和總結了一下現有的Go 語言的包依賴管理。

自那以后,Go 語言又有了一些新的進展,比如從 1.6 版本引入的 vendor 的概念, 算是在解決包管理問題上的出現新趨勢,也催生出 govendor 這樣新的工具出來。

不過包管理并不是這篇文章想要主要討論的問題。此前,通過 The Go Programming Language 這本書,我系統地對 Go 語言各個方面的使用以及部分設計和實踐有了更全面的了解, 因此這次以更加“正確”的方法實現了 wu 這個小工具,在此將從構思到實現各方面的思考記錄一些下來, 也算是分享一點經驗。在這篇文章中,我們會談到 os/exec 、 flag 、 time 、 encoding/json 、 os/signal 等庫的使用。

wu 的代碼已經 開源在 GitHub 上 并提供 macOS 和 Linux 平臺下 編譯好的可執行文件

構思和準備

在開始著手實現之前,對于要寫什么和怎么實現等方面都進行了一些思考。簡單來說, 我首先確定了想要寫一個通過監聽文件系統修改,從而可以自動重啟命令的工具。 寫這樣一個工具的原因是部分 Light weight 的 web framework 并沒有內置自動重新加載的功能。 這樣每次修改完代碼就需要手動的結束原來的 Server 并重新啟動。寫一個小工具來自動化這一過程看起來是個不錯的主意。

在進一步的構思之前,我簡單的進行了一些檢索,參考了一些同類的工具(主要是 NodeJS 社區內的一些解決方案), 確定了一些實現的目標:

  1. 這個工具應該非常簡單和輕量,它所完成的事情就應該是簡單的監視文件和執行命令, 最多添加一些簡單的配置,不應需要繁復的設置
  2. 這個工具應該具有最少的依賴,相比于 Gulp 和 Grunt 等 NodeJS 的解決方案, 這一工具應該可以說以便攜式可執行文件的方式分發,這也是選擇 Go 來實現的一個優勢
  3. 可配置,應該有一個簡單的配置文件,使得用戶可以記錄下來執行的選項,從而不需要每次敲打復雜的命令

以上三點決定了 wu 的大部分設計,除此之外,在實現之前我也預先考慮了一下可能遇到的問題:

  1. 進程通訊的問題:因為 wu 本質上是需要通過啟動子進程來運行命令的,因此就需要考慮如何與子進程進行通訊, 如何獲得子進程運行的狀態,如何強制結束子進程等等問題都需要事先進行一定的研究
  2. 并發和并行的問題:除了需要啟動和維護子進程,我們的工具還要偵聽文件系統的改變, 隨著用戶的操作很多事件會并發的產生,如何正確地處理這些并發也是一個重要的問題。 幸好我們是在使用 Golang 解決這一問題,在很多地方 Go 語言確實為并發控制提供了很棒的解決方案。
  3. 多個文件同時寫入問題:這個問題和并發問題比較類似,因為用戶可能同時寫入多個文件, 這時激發多次進程重啟絕不是我們想要的結果。把一段時間內接受到的寫入事件合并成一個來處理是一種可能的解決方案。

我們的應用的主要流程大體上可以用下圖表示:

從圖中可以看出應用的主循環是不斷的 Start 和 Terminate 子進程的過程。其中只有紅色的 Wait and gather changed files 步驟會處理文件系統傳來的信號,這一步驟也是阻塞的,也就是說如果如果沒有信號出現,應用會一直停留在這一步。 我們在這里可以做一個等待,也就是說可以給這一步設定一個最短運行事件,讓程序將這一段時間出現的所有文件處理事件都捕捉下來, 從而避免一次保存多個文件導致的重復執行。反過來,在這個階段之后接收到的文件修改事件將會被留到下一個循環當中處理。 Go 語言的 channel 設計可以讓我們方便地做到這一點。

文件系統偵聽

除了預先考慮一些可能會遇到的問題,我們還要對實現要用到的工具做一個預先的調查。 其中最重要的當然是文件系統改變如何進行偵聽的問題。在這里我們使用了一個提供了 Go 語言接口庫 fsnotify ,使用之前我們需要使用 go get 命令先將對應包下載到本地環境中。

go get github.com/fsnotify/fsnotify

這個工具提供了獲得文件修改事件的一個較為底層的訪問接口,當指定偵聽的文件或文件夾之后, 我們可以獲得一個 channel 用來接收事件。fsnotify 的一點不足在于偵聽文件夾并不會遞歸進行, 也就是當使用它偵聽了某一文件夾時,這個文件夾子目錄下的修改并不會被捕捉到,因此我們必須手動完成這一工作。

運行子進程

解決的監視文件系統修改的問題,就要考慮如何該進行子進程的啟動、守護和結束控制的方法了。 Go 語言的標準庫 os/exec 完全提供了我們所需的接口。我們使用:

cmd := exec.Command(name, args...)

可以獲得一個 Command 對象,通過調用 cmd.Start() 方法就可以啟動子進程。我們有兩種方法結束子進程, 一個是通過 cmd.Process.Signal 方法為進程發送一個終止信號,比如 SIGINT 或 SIGQUIT , 但是有的程序可能會忽略這些信號繼續運行,因此一個必要的邏輯就是在一個 Timeout 之后應用還沒有結束, 我們需要通過發送 SIGTERM 或 SIGKILL 信號強制終止進程。在最初的實現中,我使用了 cmd.Process.Kill() 方法來結束進程,卻發現了一些意外的問題,在后文中會詳細的介紹遇到的問題及其解決方案。

命令行參數解析

接下來就是用戶交互的方式了。盡管我們的 App 是一個極簡的設計,但是還是要給用戶一些命令行的接口, Go 語言的標準庫提供了 flag 庫方便我們完成這一任務。 flag 的功能非常簡單,他有兩種的使用方式, 一種是在 main 包的包層級聲明指針變量作為接受參數值的位置:

var value = flag.String("option", "default value", "help text")

這種方法的內部原理是 flag 在內部聲明了一個變量并返回了他的指針。同樣, flag 包也會在全局的一個單例對象上保存一個對這一變量的引用。當 flag.Parse() 被執行時, 保存在單例對象上的所有變量會被賦予解析出來的值。因為我們得到的是變量的一個指針, 因此當我們使用 *value 來獲得變量值的時候,將會獲得 flag 內部解析出來的值。

如果覺得每次都需要用取指針值操作 *value 來獲得變量值的話,我們可以通過在 main 包中的 init 方法中獲取值的方法來實現同樣的功能。比如說:

var value string
func init() {
    flag.StringVar(&value, "option", "default value", "help text")    
}

可見,我們傳給 StringVar 的仍然是 value 變量的指針, StringVar 在內部實際進行的是跟以前類似的操作, 最終當我們執行 flag.Parse() 后, value 的值也會被變成解析出來的值。這里我們可以看到, 指針的存在使得一些原來可能很難表現的操作變得簡單,但是同時也產生了很多的副作用。 比如說,在并發程序中,因為一個變量的引用被傳遞到多個未知的地方,一段簡單的函數的運行過程當中, 變量的值可能會發生意想不到的改變,因而造成了預期之外的結果。在我們實現這一工具的過程當中也會遇到類似的問題需要解決。

好在 flag 操作本身比較簡單,也不會并發和重復,這樣使用還是安全的。另外,上述情況中我們都是在 main 函數執行之前進行 flag 參數的聲明工作,這是為了確保 main 函數一開始執行 flag.Parse 的時候,所有參數已經聲明完畢。 我們自然也可以在函數里再進行這些工作,只要能確保執行的順序即可。此外,如果你在同一個包的多個文件里聲明了多個 init 函數, 這些函數雖然都會被執行,但是執行的順序是未定義的行為,這也是需要注意的地方。

時間

接下來是 time 庫的一點介紹,我們使用 time 庫來實現 sleep 和 wait 的功能。簡單來說,如果想要阻塞一個 goroutine 一段時間, time.Sleep(duration) 和 <-time.After(duration) 兩種方法都可以使用。請注意第二種方法中的 <- , time.After 方法實際上返回一個 <-chan struct{} ,這個通道會在給定事件之后接受到一個消息,因此當你一開始使用 <- 操作符試圖從通道中取消息時,通道會被阻塞,從而獲得與 Sleep 一樣的效果。這個方法實際上是非常有用的——尤其是跟 select 語句配合使用的。當然 time 庫還提供了 time.Tick 等多種方法,可供我們獲得各種各樣基于時間信號的通道。

JSON 讀寫

為了引進配置文件功能,我們還引入了 encoding/json 庫,在這一工具當中,我們只使用了非常簡單的 Decode 和 Encode 功能。 要 Decode 一個文件當中包含的 JSON,使用下面的方法:

func readJSON(filename string) SomeType {
    file, err := os.Open(filename)
    defer file.Close()

    if err == nil {
    var obj SomeType
    if err := json.NewDecoder(file).Decode(&obj); err != nil {
        // Fatal
    }
    return obj
    }
    // Fatal
}

由于我們希望輸出的 Config 文件的 JSON 是有縮進的,在寫入時我們使用 json.MarshalIntent 方法將 JSON 輸出到 []byte 中,再直接用 file 的 Write 方法寫入文件。

func writeJson(filename string, obj SomeType) {
    file, err := os.Create(filename)
    defer file.Close()

    if err != nil {
    // Fatal
    }
    if bytes, err := json.MarsalIntent(conf, "", "  "); err == nil {
    file.Write(bytes)
    } else {
    // Fatal
    }
}

其中, SomeType 是一個為接受 JSON 數據而定義的 strunt{} ,值得注意的是,只有 Public 的元素才能被 encoding/json 庫讀出和寫入。

POSIX Signal 處理

最后一個值得注意的地方就是,如果用戶通過 CTRL-C 向我們發送了終止進程的信號的話,我們如何才能優雅地結束程序。 不做任何操作的情況下程序可能會立刻停止,從而導致我們啟動的子進程仍然在持續運行,從而形成了無人監管的幽靈進程。 因此我們有必要捕捉應用程序接受到的 Interrupt 信號,從而可以在此后執行一定的清理操作并終止子進程。

這一點可以通過 os/signal 庫完成,下面的代碼給出了一個簡單的例子:

func main() {
    ch := make(chan os.Signal)
    signal.Notify(ch, os.Interrupt)

    for sig := range ch {
    // Signal received!
    }
}

實現與調試

經過多方研究做好了充分的準備之后,我們終于可以著手編寫我們的主程序了。在下面的實現當中, 我們將會大量使用通道(channel)作為 goroutine 之間通訊和同步的工具。同時我們也用到了一些 Go 語言的使用模式和最佳實踐。

Runner 的實現

首先的需求就是,我們希望以面向對象的方式將我們的主運行循環包裝成對象,這樣當我們通過 os/signal 捕捉到用戶傳來的信號時,就可以通過一個方法來執行退出循環的方法。同樣我們也需要一個結構體來保存進行執行的狀態, 比如說保留一個對執行 Command 的引用等。

在這里我們要使用到一個簡單的模式,那就是如何模仿一般面向對象當中的構造函數模式。總所周知, 由于 Go 語言面向對象的實現模式不同,我們沒法強制用戶在新建對象和結構體的時候一定要執行我們指定的某一函數。 比如說用戶總可以通過 SomeType{"some", "params"} 字面量的形式來生成新的 struct{} 對象。 這在需要對結構體字段正確性進行驗證或對某些字段進行自動初始化的時候很不方便。

然而如果更換一個思路,我們其實可以保證用戶新建對象時一定要通過構造函數進行。最簡單的方法就是通過定義子包來進行訪問控制。 我們知道,在一個包中小寫字母開通的類型、函數和變量外部都不可以訪問,因此通過如下步驟我們就可以模仿傳統的構造函數模式了:

  1. 定義一個子包,比如 github.com/shanzi/wu/runner
  2. 在子包中定義一個公共的接口,比如 Runner
  3. 在子包中定義一個私有的結構體類型,比如 runner
  4. 為公共的接口 Runner 聲明一個構造函數,這個構造函數返回私有的結構體類型 runner

由于 Runner 是一個接口( interface ),它為外界提供了一個類似鴨子類型的方法提示。在 Go 語言的設計當中, 一個類型服從一個接口并不需要顯式地聲明出來——只要類型提供了接口所聲明的所有方法即可。 這一有趣的設定使得外界可以通過接口的定義在一定成都上窺探出所接受到的對象的內部結構而不需要知道對象具體的類型, 因此如果我們在 Runner 的構造函數中放回一個 runner 對象的時候,外界就將 runner 當作 Runner 所定義的那樣使用, 從而實現了暴露 runner 所提供的方法的目的。反過來,因此 runner 是私有的結構體,外界也不能直接訪問和構造出它的對象來。

由于 Go 語言的接口只能定義方法,如果外界想要獲得結構體的屬性,就必須通過 Getter 和 Setter 方法。 在我們的設計中,希望將應用偵聽的目錄路徑、偵聽的文件匹配模式和命令暴露出來,因此定義了如下的接口和結構體:

type Runner interface {
    Path() string
    Patterns() []string
    Command() command.Command
    Start()
    Exit()
}

type runner struct {
    path     string
    patterns []string
    command  command.Command

    abort chan struct{}
}

func New(path string, patterns []string, command command.Command) Runner {
    return &runner{
        path:     path,
        patterns: patterns,
        command:  command,
    }
}

注意到,我們在 runner 結構體當中添加了一個沒有暴露的 abort 通道,這個通道配合 select 將會為我們提供一個優雅地結束 goroutine 的方法: 由于我們并不能從外部強制結束另一運行當中的 goroutine, 因此我們需要通過通道傳遞信號來通知 goroutine 結束運行。在介紹這個以前,我們先來看 Start 方法的實現:

func (r *runner) Start() {
    r.abort = make(chan struct{})
    changed, err := watch(r.path, r.abort)
    if err != nil {
        log.Fatal("Failed to initialize watcher:", err)
    }
    matched := match(changed, r.patterns)
    log.Println("Start watching...")

    // Run the command once at initially
    r.command.Start(200 * time.Millisecond)
    for fp := range matched {
        files := gather(fp, matched, 500*time.Millisecond)

        // Terminate previous running command
        r.command.Terminate(2 * time.Second)

        log.Println("File changed:", strings.Join(files, ", "))

        // Run new command
        r.command.Start(200 * time.Millisecond)
    }
}

可以看到, Start 函數其實包含了我們應用主循環的全部內容,它首先構造一個新的 abort 通道, 傳遞進入 watch 函數調用 fsnotify 開始監聽工作,然后通過 range 開始我們的主循環。 在這里 changed 和 matched 都是新生成的通道, changed 輸出對當前目錄下所有文件監聽獲得的事件, matched 輸出將 changed 當中事件以文件模式匹配過濾后的機構。在這里我們對通道的使用非常像 Python 當中的生成器。不但如此,我們通過將通道串聯起來,還可以進行逐級過濾,從而在最后只獲得我們關心的內容。 在這里通道不但可以被看作生成器,也可以被看作有些編程語言提供的 Lazy Sequence (惰性求值列表)。 實際上,很多 Gopher 直接將 Channel 當成一個高效且線程安全的隊列使用。這種生成器/過濾器也是一種常用的模式。

回到我們的 watch 函數,我們將從這一函數的實現中看到 abort 通道是如何發揮作用的。

func watch(path string, abort <-chan struct{}) (<-chan string, error) {
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        return nil, err
    }

    for p := range list(path) {
        err = watcher.Add(p)
        if err != nil {
            log.Printf("Failed to watch: %s, error: %s", p, err)
        }
    }

    out := make(chan string)
    go func() {
        defer close(out)
        defer watcher.Close()
           for {
            select {
            case <-abort:
                // Abort watching
                err := watcher.Close()
                if err != nil {
                    log.Fatalln("Failed to stop watch")
                }
                return
            case fp := <-watcher.Events:
                if fp.Op == fsnotify.Create {
                    info, err := os.Stat(fp.Name)
                    if err == nil && info.IsDir() {
                        // Add newly created sub directories to watch list
                        watcher.Add(fp.Name)
                    }
                }
                out <- fp.Name
            case err := <-watcher.Errors:
                log.Println("Watch Error:", err)
            }
        }
    }()

    return out, nil
}

我們可以看到,在 watch 函數中,我們開啟了一個新的 goroutine,在這個 goroutine 當中我們進行了以下工作:

  1. 如果 abort 通道返回,結束監聽,函數返回
  2. 如果有文件事件產生,進行初步過濾和處理,對于新建的文件夾,要在這里顯式加入偵聽當中
  3. 發現文件錯誤,通過 Log 打印出來并忽略

我們把這個 select 語句包含在一個永真 for 循環中,這樣除非 abort 信號獲得消息,其他的消息處理之后就會立即進入新的循環。

看過了 Start 函數,我們來看 Exit 函數的實現:

func (r *runner) Exit() {
        log.Println()
        log.Println("Shutting down...")

        r.abort <- struct{}{}
        close(r.abort)
        r.command.Terminate(2 * time.Second)
}

可以看到,除了一些打 Log 的工作, Exit 函數的主要工作就是向 abort 通道中傳遞信息并關閉它。最后結束我們的 Command。

在這里必須簡單提到一點,對于這種傳遞的消息不包含信息量而只有消息的到達本身有含義的通道為什么我們要通過傳遞 struct{} 類型而不是寫起來更短的 int 或者 bool 來完成呢?這是因為在 Go 語言中只有 struct{}{} 是不占用空間的——他的 size 是 0 。 其他任何類型都不能保證除了通道內部本身的空間使用之外不添加新的空間占用。因此無論是這種通道,還是我們希望使用 map 模擬集合,都應該用 struct{}{} 做為值的類型。

Command 的實現

為了方便我們對子進程的管理,wu 的實現當中,我們還將 Command 相關的操作封裝到了 github.com/shanzi/wu 包當中, 下面給出了 Command 包定義的接口和構造函數的定義是:

type Command interface {
        String() string
        Start(delay time.Duration)
        Terminate(wait time.Duration)
}

type command struct {
        name   string
        args   []string
        cmd    *exec.Cmd
        mutex  *sync.Mutex
        exited chan struct{}
}

func New(cmdstring []string) Command {
        if len(cmdstring) == 0 {
                return Empty()
        }

        name := cmdstring[0]
        args := cmdstring[1:]

        return &command{
                name,
                args,
                nil,
                &sync.Mutex{},
                nil,
        }
}

可以看到,在結構體當中,我們除了保存一些傳進來的參數,還用 cmd 字段保存了當前運行的 Command 的引用, 此外,因為多個方法可能會并發地修改結構體中元素的值,我們使用 sync 類提供的 Mutex 鎖來實現對象的互斥訪問。 最后還保留了一個 Channel 用來在進程結束之后獲得通知。

Command 的 Start 方法是 wu 當中最復雜的一部分,它首先會強制 Sleep 一段時間,以免子進程在很短的時間里被重復啟動, 此后通過 mutex 加鎖獲得對 command 結構體對象修改的權限,隨后構造和啟動子進程,并在新的 goroutine 里通過 cmd.Wait() 來等待進程結束。當進程結束之后將會打印 Log 并通過 exited 通道發布結束消息。

func (c *command) Start(delay time.Duration) {
        time.Sleep(delay) // delay for a while to avoid start too frequently

        c.mutex.Lock()
        defer c.mutex.Unlock()

        if c.cmd != nil && !c.cmd.ProcessState.Exited() {
                log.Fatalln("Failed to start command: previous command hasn't exit.")
        }

        cmd := exec.Command(c.name, c.args...)

        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stdout // Redirect stderr of sub process to stdout of parent

        // Make process group id available for the command to run
        cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

        log.Println("- Running command:", c.String())

        err := cmd.Start()
        exited := make(chan struct{})

        if err != nil {
                log.Println("Failed:", err)
        } else {
                c.cmd = cmd
                c.exited = exited

                go func() {
                        defer func() {
                                exited <- struct{}{}
                                close(exited)
                        }()

                        cmd.Wait()
                        if cmd.ProcessState.Success() {
                                log.Println("- Done.")
                        } else {
                                log.Println("- Terminated.")
                        }
                }()
        }
}

Command 的 Terminate 方法也利用到了 select 語句,它的主要邏輯是,先給子進程發送 SIGINT 信號促使子進程自然退出, 此后用 select 同時偵聽 exited 通道和 time.After(wait) 通道,以便在 SIGINT 失效的情況下設法強制退出。 前面提到過, time.After(wait) 會返回一個在給定時間后發送消息的通道,這里使用 select 從兩個通道當中選擇先得到的消息, 因此當 wait 時間過后 exited 還沒有消息傳來,就會進入強制退出的分支。這就是一般在 Go 語言中實現 Timeout 的模式或者說方法。

目前為止,已經將 wu 的主要代碼邏輯介紹完了,在之后的調試當中,主要發現和修正了兩個比較嚴重且有代表性的問題, 那就是空命令的問題和結束命令的問題。

Empty Command

第一個問題在于,如果用戶沒有給定運行的 Command 程序應該如何處理的問題。在 wu 里,我選擇了什么也不做。 在這里,我們并沒有通過分支語句來在函數中進行判斷。得益于 Go 語言接口類型的設計,我們并不一定要在 Command 構造函數里返回 command 結構體——任何服從 Runner`接口的類型皆可。為此,我們可以使用最簡單的方式定義一個空的 Command 類。

// An empty command is a command that do nothing
type empty string

func Empty() Command {
        return empty("Empty command")
}

func (c empty) String() string {
        return string(c)
}

func (c empty) Start(delay time.Duration) {
        // Start an empty command just do nothing but delay for given duration
        <-time.After(delay)
}

func (c empty) Terminate(wait time.Duration) {
        // Terminate empty command just return immediately without any error
}

Kill Command

之前提到過, os/exec 包中的 Command 類其實可以通過 cmd.Process.Kill() 方法來結束。在一般的執行當中都取得了成功。 但是我卻發現當使用 wu go run main.go 啟動一個 Web Server 時,在文件修改后舊的子進程總是不能被正確地結束。 顯示發送 SIGINT 沒有效果,之后執行了 Kill 函數之后,盡管 go run 命令退出,但是 Web Server 仍然在運行, 因此導致了端口占用的問題,使得新的 Command 執行失敗。

經過檢索后發現,這是因為 go run 命令實際上相當于 build 和執行兩條命令,它本身也是通過子進程來運行編譯好的新進程, 因此當信號發送給 go run 時,它運行的子進程本身沒有收到 SIGINT 因此并不會退出。 go run 也因為一直等待子進程而保持運行。 最后當執行 Kill 函數之后,只有 go run 命令被結束,而他的子進程仍然在執行當中。

知道原因之后就可以提出解決方案了,首先在執行 Command 之前,我們要強制新的 Command 和他的子進程可以獲得 Group PID。

// Make process group id available for the command to run
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

此后,我們需要自己實現一個 kill 函數,為整個子進程組都發送同樣的信號:

func (c *command) kill(sig syscall.Signal) error {
        cmd := c.cmd
        pgid, err := syscall.Getpgid(cmd.Process.Pid)
        if err == nil {
                return syscall.Kill(-pgid, sig)
        }
        return err
}

由此,我們模擬了用戶在按下 CTRL-C 后 Shell 的行: 為整個進程組發送結束信號。這樣,我們運行的 Command 就可以保證被正確結束了。當然,這一套操作只在 *NIX 操作系統上可用。在 Windows 上并沒有這樣的信號機制—— 還好,wu 并不需要支持 Windows。

總結

wu 是我完成的又一個有點規模的 Go 語言的應用。由于這次對于 Channel 和 Go 的一些理念有了更深的認識, 編寫代碼也更加順暢,也更能體會出一歇 Go 設計上的優點了。

Go 語言在很多方面的設計上確實有獨到之處,比如使用通道作為并發同步的工具,而 Channel 的作用又不僅限于此, 它還可以用來模擬隊列、生成器、惰性列表,用來實現多級過濾模式等等。Go 語言的接口和面向對象的設計在很多時候也非常靈活。

然而,沒有范型使得容器類難以實現,沒有異常捕捉使得很多函數調用有點啰嗦等問題也確實為代碼的編寫引入了一些麻煩。 雖然引入了 Vendor ,支持 internal 包的概念,但是總體來說 Go 語言在包管理上仍然有很大的提升空間。

我個人使用 Go 語言的體驗還是不錯的,接下來一段時間仍將在這門語言上在做一些研究。

最后,如果你對 wu 的詳細實現感興趣,它的代碼已經 開源在 GitHub 上 , 我還上傳了編譯好的可執行文件 可供下載 ,歡迎 Bug 反饋和代碼貢獻。

 

來自:http://io-meter.com/2016/08/14/build-a-go-commmand-line-tool/

 

 本文由用戶 61863143 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!