Cobra로 간단한 CLI 만들기

검프·2021년 6월 26일
4
post-thumbnail

이 글의 내용 중 일부는 Go 언어 실전 테크닉을 참고하여 작성했습니다.


Saturday Night 스터디를 진행하면서 처음 Go 언어를 접하는 분들에게 Go 언어의 장점에 대해서 소개할 기회가 있었습니다. 여러가지를 설명해 봤지만, 잘 어필된다는 느낌을 받지 못하겠더군요. 사실 대부분의 프로그래밍 언어들이 튜링 컴플리트Turing completeness^{Turing\ completeness}하기 때문에 "이건 Go 언어만의 장점으로 볼 수 없겠는데?" 하는 부분도 많기는 합니다. 개인적으로 Go 언어의 가장 큰 장점이라고 생각하는 동시성 지원 조차도 실무적으로 많이 사용할 기회가 없는 개발자 분들도 있고, 주 사용 기술 스택을 변경해야 할만큼의 매력이 있지는 않을 수 있습니다.

그래도 Go 언어의 쓸모(?)를 알리기 위해서 머리를 굴리다가. CLI를 떠올렸습니다. 많은 개발자 분들이 이미 실무에서 CLI를 사용하고 계실거고, CLI의 장점을 잘 알고 계실거라 생각했기 때문에 CLI를 만들는데 편리한 기술로서 Go 언어를 추천해 보자고 생각을 했습니다.


왜 Go로 CLI를 만들까?

CLI를 만들때 Go를 사용하는 이유는 크게 3가지입니다.

배포의 편리함

Go 언어는 컴파일 언어로, 프로그램을 실행 가능한 바이너리Excutable binary^{Excutable\ binary}로 만들 수 있도록 지원합니다.

다양한 플랫폼으로의 이식성

Go 언어로 만든 프로그램은 크로스 컴파일을 통하여 다양한 OS와 하드웨어에서 실행 가능한 파일을 손쉽게 만들 수 있습니다.

성능

Go 언어로 만든 프로그램은 저수준 언어 만큼이나 빠른 성능을 보여줍니다. 특히 CLI는 커맨드를 입력하는 순간 빠르게 반응하는 것이 중요한데, Go 언어로 작성된 프로그램은 매우 빠른 Start-up 속도를 보장합니다.

flag 패키지

Go 언어는 CLI를 만들 수 있도록 지원하는 flag 패키지를 내장하고 있습니다. flag 패키지를 이용한 CLI는 싱글 커맨드 패턴이라는 불리는 커맨드라인 패턴을 구현합니다. 유닉스 철학과 같은 최소 주의. 하나의 작업에만 충실한 간단한 CLI를 만들고 싶을 때 flag 패키지가 유용합니다.

EXECUTABLE [options] [args]
  • EXECUTABLE : 실행 파일명
  • options : 동작 방식에 영향을 주는 옵션 인수
  • args : 프로그램에 전달하는 인수

이 글에서는 flag 패키지를 다루지는 않습니다.

Cobra 소개

Cobra는 정적 사이트 생성기인 hugo의 개발자인 Steve Franciaspf13^{spf13}님이 개발한 패키지로, CLI를 쉽게 만들 수 있도록 해주는 강력한 라이브러리입니다. Kubernetes, Hugo, Github CLI 등 다양한 프로젝트에서 널리 사용되고 있습니다. Cobra는 flag 패키지와 달리 서브 커맨드 패턴을 구현합니다. 서브 커맨드 패턴은 하나의 CLI가 다양하고 복잡한 작업을 지원하고자 할때 많이 사용됩니다.

EXECUTABLE [command] [options] [args]
  • EXECUTABLE : 실행 파일명
  • command : 동작을 결정
  • options : 동작 방식에 영향을 주는 옵션 인수
  • args : 프로그램에 전달하는 인수

Cobra는 Command라는 구조체에 커맨드명과 사용 방법을 정의하는 것으로 CLI를 구성해 나갑니다.

샘플 애플리케이션

Cobra 라이브러리를 공부하는 것은 Github readme를 참고하면 될 것 같고, 이 글에서는 특정 URL에 포함된 이미지 파일을 다운로드하는 iget이라는 간단한 CLI를 만들어 보도록 하겠습니다.

Set 구현

URL에서 파싱한 이미지 URL을 관리하기 위해 Set을 사용하겠습니다. Go 언어에서는 Set을 지원하지 않습니다. 왜 Set을 지원하지 않는지는 정확히 알 수 없으나, Go 언어 개발팀이 단순함의 철학을 유지하기 위해서 구현하지 않았다는 추측들을 확인할 수 있었습니다. 개인적으로는 납득히 되지는 않네요.

아쉽긴 하지만 map이 기능상 set의 상위 호환이 가능하므로 map을 이용해 set을 어렵지 않게 구현할 수 있습니다. 빈 구조체는 메모리를 사용하지 않기 때문에 map에 저장할 값으로 적당합니다. void라는 타입을 정의하고 map에 저장할 값으로 void 타입을 사용합니다.

// set.go
// 빈 구조체는 메모리를 사용하지 않음
type void struct{}

var marking void

type Set struct {
	m map[string]void
}

func NewSet() *Set {
	return &Set{make(map[string]void)}
}

// Add, Remove, Contains, Len, Entries, String 메서드를 구현
func (s *Set) Add(value string) {
	s.m[value] = marking
}

func (s *Set) Remove(value string) {
	delete(s.m, value)
}

func (s *Set) Contains(value string) bool {
	_, c := s.m[value]
	return c
}

func (s *Set) Len() int {
	return len(s.m)
}

func (s *Set) Entries() []string {
	entries := make([]string, 0, len(s.m))
	for k := range s.m {
		entries = append(entries, k)
	}

	return entries
}

func (s *Set) String() string {
	return fmt.Sprintf("[%s]", strings.Join(s.Entries(), ", "))
}

fetcher 패키지

fetcher 패키지는 URL에서 HTML 문서와 이미지 파일을 다운로드하는 유틸리티 성격의 패키지입니다. HTML 문서로 부터 이미지 엘리먼트를 찾고 이미지 엘리먼트에서 이미지 URL을 추출하는 역할도 합니다. iget은 Cobra를 사용방법을 보여주는 간단한 샘플 애플리케이션이기 때문에 HTML 문서 구조가 복잡할 경우 이미지 URL을 추출하지 못합니다. 추후 더 개선해보겠습니다.

// fetcher.go
func ReadHtml(urlString string) (string, error) {
	resp, err := http.Get(urlString)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("%d %s", resp.StatusCode, resp.Status)
	}

	htmlBytes, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	return string(htmlBytes), nil
}

func DownloadAtPath(urlString, atPath string) error {
	resp, err := http.Get(urlString)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	out, err := os.Create(atPath)
	if err != nil {
		return err
	}
	defer out.Close()

	_, err = io.Copy(out, resp.Body)
	return err
}

HTML 문서에서 정규표현식을 이용하여 이미지 URL을 추출합니다. 이미지 URL의 중복을 제거하기 위해서 Set을 사용합니다.

// parser.go
var imgElementRegex = regexp.MustCompile("<img.*?src=\"(.*?)\"[^\\>]+>")
var urlRegex = regexp.MustCompile("(http:\\/\\/|https:\\/\\/)[a-z0-9]+([\\-\\.]{1}[a-z0-9]+)*\\.[a-z]{2,5}(:[0-9]{1,5})?(\\/.*\\.(jpg|jpeg|png|gif|svg|webp))?")

func ParseImgUrls(html string) *collections.Set {
	imgElements := imgElementRegex.FindAllString(html, -1)
	set := collections.NewSet()
	for _, elem := range imgElements {
		urls := urlRegex.FindAllString(elem, -1)
		for _, urlString := range urls {
			fmt.Println(fmt.Sprintf("Found [%s]", urlString))
			set.Add(urlString)
		}
	}

	return set
}

savepath 패키지

iget을 사용하는 사용자의 다운로드 디렉터리에 다운로드 받은 이미지를 저장할 계획인데요, 저장 경로를 관리하기 위해서 savepath라는 패키지를 구현했습니다. Go 언어에서 사용자의 다운로드 디렉터리를 확인하는 방법을 찾지 못했지만, os.UserHomeDir() 함수를 이용해서 홈 디렉터리는 조회할 수 있습니다.

SavePath 구조체는 이미지 파일을 저장할 디렉터리명을 받아서 사용자홈/다운로드/이미지디렉터리 형태의 저장 경로를 생성합니다. 이 경로를 가지고 디렉터리 생성과 파일 저장 경로 제공 등의 서비스를 제공합니다.

// savepath.go
type SavePath struct {
	savePath string
}

func New(dirName string) *SavePath {
	savePath, err := savePath(dirName)
	if err != nil {
		panic(err)
	}

	return &SavePath{savePath}
}

func savePath(dirName string) (string, error) {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		homeDir, err = os.Getwd()
		if err != nil {
			return "", err
		}
	}

	return fmt.Sprintf("%s%cDownloads%c%s", homeDir, os.PathSeparator, os.PathSeparator, dirName), nil
}

func (s *SavePath) WithUrl(urlString string) string {
	return fmt.Sprintf("%s%c%s", s.savePath, os.PathSeparator, path.Base(urlString))
}

func (s *SavePath) Create() error {
	if _, err := os.Stat(s.savePath); os.IsNotExist(err) {
		return os.Mkdir(s.savePath, os.ModePerm)
	}

	return nil
}

downloader 패키지

iget CLI의 가장 핵심 기능을 구현하는 패키지입니다. Downloader 구조체는 URL을 입력으로 받고 Get()이라는 함수로 서비스를 제공합니다. Get() 함수는 URL에서 HTML 문서를 다운로드 받은 후 이미지 URL을 추출합니다. 그리고 고루틴을 이용하여 추출한 이미지 URL을 다운로드합니다.

// downloader.go
type Downloader struct {
	urlString string
	savePath  *savepath.SavePath
}

func New(urlString string) *Downloader {
	return &Downloader{
		urlString: urlString,
		savePath:  savepath.New(domainFromUrl(urlString)),
	}
}

func (d *Downloader) Get() error {
	fmt.Println(fmt.Sprintf("Loading HTML from a %s.", d.urlString))
	html, err := fetcher.ReadHtml(d.urlString)
	if err != nil {
		return err
	}

	fmt.Println(fmt.Sprintf("Parse image URLs from HTML."))
	urls := fetcher.ParseImgUrls(html)
	numOfImages := urls.Len()

	if numOfImages == 0 {
		fmt.Println("Image URL not found.")
		return nil
	} else {
		fmt.Println(fmt.Sprintf("Found %d image URLs.", numOfImages))

		err := d.savePath.Create()
		if err != nil {
			return err
		}
	}

	d.downloadConcurrency(urls)

	return nil
}

func (d *Downloader) downloadConcurrency(urls *Set) {
	wg := sync.WaitGroup{}
	wg.Add(urls.Len())

	download := func(urlString, path string) {
		defer wg.Done()

		err := fetcher.DownloadAtPath(urlString, path)
		if err != nil {
			fmt.Println(err)
		} else {
			fmt.Println(fmt.Sprintf("I got it [%s]", path))
		}
	}

	for _, urlString := range urls.Entries() {
		go download(urlString, d.savePath.WithUrl(urlString))
	}
	wg.Wait()
}

func domainFromUrl(urlString string) string {
	u, err := url.Parse(urlString)
	if err != nil {
		fmt.Println(err)
	}

	return u.Host
}

아래는 사용자 코드입니다. Downloader 인스턴스를 생성한 후 Get() 함수를 호출하는 것이 전부인 간단한 예제입니다. 이제 만들어진 라이브러리를 Cobra를 이용하여 CLI로 만들어 보겠습니다.

// downloader_test.go
func TestGet(t *testing.T) {
	urlString := "https://velog.io/@kineo2k"

	dl := New(urlString)
	err := dl.Get()
	if err != nil {
		t.Fail()
	}
}

Cobra 사용하기

먼저 Cobra를 설치합니다. Cobra를 설치하면 cobra CLI가 제공됩니다. Cobra CLI를 사용하면 손쉽게 디렉터리 구조 생성과 커맨드를 추가할 수 있습니다. 자세한 내용은 공식 문서를 참고해주세요.

> go get -u github.com/spf13/cobra
> cobra
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.

Usage:
  cobra [command]

Available Commands:
  add         Add a command to a Cobra Application
  help        Help about any command
  init        Initialize a Cobra Application

Flags:
  -a, --author string    author name for copyright attribution (default "YOUR NAME")
      --config string    config file (default is $HOME/.cobra.yaml)
  -h, --help             help for cobra
  -l, --license string   name of license for the project
      --viper            use Viper for configuration (default true)

Use "cobra [command] --help" for more information about a command.

Cobra 애플리케이션은 프로젝트에 /cmd 디렉터리를 생성한 후 커맨드를 구현하는 것이 관례입니다. iget에서는 아래 2가지 서브 커맨드를 구현하겠습니다.

  • version : iget CLI의 버전을 출력
  • get : iget CLI를 이용한 이미지 다운로드

version 커맨드 구현

Cobra의 Command 구조체를 이용하여 커맨드의 세부 사항을 구현합니다.

  • Use : 서브 커맨드명을 입력합니다.
  • Short : 서브 커맨드의 설명을 입력합니다.
  • Run : 커맨드 실행 시 호출되는 함수로 해당 커맨드를 구현합니다.

version 커맨드에서는 앱이름과 버전 정보를 출력합니다.

// cmd/version.go
var versionCmd = &cobra.Command{
	Use:   "version",
	Short: "Output the version number.",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println(fmt.Sprintf("%s version %s", constants.AppName, constants.Version))
	},
}

get 커맨드 구현

get 커맨드에서는 미리 구현한 Downloader를 이용해서 프로그램을 완성합니다.

  • Use : 서브 커맨드명을 입력합니다.
  • Short : 서브 커맨드의 설명을 입력합니다.
  • Example : 서브 커맨드의 이용 방법 예시를 입력합니다.
  • Args : Run 함수가 실행되기 전에 전달된 인자를 검증하는 목적으로 호출이되는 함수로 에러를 리턴하면 커맨드 실패 메시지로 에러 내용을 출력해줍니다. 여기서는 인자가 1개인지와 URL 형식에 부합하는지를 검증했습니다.
  • Run : 커맨드 실행 시 호출되는 함수로 해당 커맨드를 구현합니다. Run 함수에서 인자를 검증했기 때문에 별도의 입력 검증은 진행하지 않아도 안전합니다.
// cmd/get.go
var getCmd = &cobra.Command{
	Use:     "get",
	Short:   "Download the images included in the URL.",
	Example: "iget get [URL containing images]",
	Args: func(cmd *cobra.Command, args []string) error {
		if len(args) != 1 {
			return errors.New("enter the URL")
		}

		_, err := url.ParseRequestURI(args[0])
		if err != nil {
			return errors.New("invalid URL")
		}

		return nil
	},
	Run: func(cmd *cobra.Command, args []string) {
		urlString := args[0]

		dl := downloader.New(urlString)
		err := dl.Get()
		if err != nil {
			panic(err)
		}
	},
}

root 커맨드 구현

위에서 version, get 커맨드는 정의만 했을 뿐 아직 사용하지 않았습니다. cmd 패키지가 로딩될때 init() 함수에서 커맨드들이 root 커맨드에 추가되고, 이를 통해서 CLI에 통합됩니다. root 커맨드라고 해서 더 특별한 부분은 없습니다. 또한 서브 커맨드에도 다시 서브 커맨드를 추가할 수 있습니다.

rootCmd.Execute()를 실행하여 커맨드를 실행합니다. 이때 os.Args[1:]가 인자로 사용되며, 커맨드 트리를 탐색하여 적절한 명령과 옵션을 찾습니다.

// cmd/root.go
var rootCmd = &cobra.Command{
	Use:   "iget",
	Short: "CLI to download images from URL.",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("Usage: iget [command] [flags]\n\nFor more information, use help.")
	},
}

func Execute() {
	if err := rootCmd.Execute(); err != nil {
		os.Exit(1)
	}
}

func init() {
	rootCmd.AddCommand(versionCmd)
	rootCmd.AddCommand(getCmd)
}

마지막으로 main() 함수에서 커맨드를 실행합니다.

package main

import (
	cmd "iget/cmd"
)

func main() {
	cmd.Execute()
}

완성된 프로젝트 구조

iget
├── LICENSE
├── README.md
├── bin
├── cmd
│   ├── get.go
│   ├── root.go
│   └── version.go
├── collections
│   └── set.go
├── constants
│   └── constants.go
├── downloader
│   ├── downloader.go
│   └── downloader_test.go
├── fetcher
│   ├── fetcher.go
│   └── parser.go
├── go.mod
├── go.sum
├── iget.go
└── savepath
    └── savepath.go

CLI 컴파일 & 실행하기

CLI도 Go 프로그램이기 때문에 컴파일 방법도 동일합니다. 저는 macOS를 대상으로 컴파일했습니다.

> GOOS=darwin GOARCH=amd64 go build -o bin/iget
> ./bin/iget get https://velog.io/@kineo2k
Loading HTML from a https://velog.io/@kineo2k.
Parse image URLs from HTML.
Found [https://media.vlpt.us/images/kineo2k/profile/9fa79a80-3144-11ea-9fcb-55a14c1cbb42/IMG1268.png]
Found [https://media.vlpt.us/images/kineo2k/post/92cbd8aa-084e-4fd0-b04b-c40900b85122/bobra-logo.png]
Found [https://media.vlpt.us/images/kineo2k/post/cd92fd08-46b7-42be-976d-355d93fe40b0/concrete-type.webp]
Found [https://media.vlpt.us/images/kineo2k/post/d42705da-1c65-404b-be6e-62802c678dff/embedding.jpg]
Found [https://media.vlpt.us/images/kineo2k/post/9955e3fa-af9e-44d8-9096-d30675d5609a/mongodb-logo.png]
Found [https://images.velog.io/images/kineo2k/post/8995a52b-431f-42e1-92b9-fa00064c9c35/replica-set-primary-with-two-secondaries.bakedsvg.svg]
Found [https://images.velog.io/images/kineo2k/post/a7bf1db5-8740-45c2-ab35-c3fee4c955ad/gophslice.svg]
Found [https://media.vlpt.us/images/kineo2k/post/187b20ef-79b9-47dc-86b3-3c44f0a7fed3/1_CdjOgfolLt_GNJYBzI-1QQ.jpg]
Found [https://media.vlpt.us/images/kineo2k/post/ff80312b-822b-4753-81d6-4138a0880845/saturday-night-org.jpg]
Found [https://media.vlpt.us/images/kineo2k/post/95f3481a-f377-4469-a0f9-70852270f6a3/Concurrency-in-go-pic-1-1.png]
Found [https://media.vlpt.us/images/kineo2k/post/5096bff1-b1c2-45ca-a5e2-0da5310eea72/gitlab-500-error.jpg]
Found [https://media.vlpt.us/images/kineo2k/post/939e37c6-bffb-41b9-bbfa-cd585cf404c3/athens.jpg]
Found [https://media.vlpt.us/images/kineo2k/post/f85b6f79-e09a-41c9-900f-a831d7a5bcd9/be41df8a96b934821f14f5844949b52604c8b074.png]
Found 13 image URLs.
I got it [/Users/blabla/Downloads/velog.io/gophslice.svg]
I got it [/Users/blabla/Downloads/velog.io/replica-set-primary-with-two-secondaries.bakedsvg.svg]
I got it [/Users/blabla/Downloads/velog.io/Concurrency-in-go-pic-1-1.png]
I got it [/Users/blabla/Downloads/velog.io/athens.jpg]
I got it [/Users/blabla/Downloads/velog.io/bobra-logo.png]
I got it [/Users/blabla/Downloads/velog.io/mongodb-logo.png]
I got it [/Users/blabla/Downloads/velog.io/1_CdjOgfolLt_GNJYBzI-1QQ.jpg]
I got it [/Users/blabla/Downloads/velog.io/gitlab-500-error.jpg]
I got it [/Users/blabla/Downloads/velog.io/concrete-type.webp]
I got it [/Users/blabla/Downloads/velog.io/be41df8a96b934821f14f5844949b52604c8b074.png]
I got it [/Users/blabla/Downloads/velog.io/saturday-night-org.jpg]
I got it [/Users/blabla/Downloads/velog.io/embedding.jpg]
I got it [/Users/blabla/Downloads/velog.io/IMG1268.png]

제 경우 컴파일한 iget 커맨드를 사용자 홈 디렉터리에 배치해서 사용하고 있습니다. Alfred의 Shell Command 실행 기능과 함께 사용하니까 꿀조합이네요 :)


Go 언어와 Cobra를 이용하면 간단하게 CLI를 만들 수 있습니다. 업무에 필요한 도구나 자동화를 하는데 도움이 됐으면 합니다.

profile
권구혁

2개의 댓글

comment-user-thumbnail
2021년 6월 26일

cobra 패키지 설치하면 cobra CLI도 설치되는 줄은 몰랐네요.. 앱 초기화 기능도 제공하는군요...지금까지 하나하나 직접 만들었는데 😑

1개의 답글