Goでコマンドラインツールを試す

goでコマンドラインツールを作ってみたメモです。


世の中にgo製のコマンドラインツールがたくさんありますが、どうやって作るのんだろうと調べてみたメモです。

goでは各プラットフォーム用に実行可能なバイナリを生成出来るので、
コマンドとして機能するプログラムを書いて、各プラットフォーム毎にバイナリを生成すればよさそうです。

flagパッケージ

コマンドライン引数を自分で処理するのは大変ですが、標準のflagパッケージを使うと簡単に扱えるようです。
(それでも機能が薄いようで、コマンドライン引数を扱うライブラリが色々あるようです)
http://golang.jp/pkg/flag

flagパッケージでは

command -list

のようなオプションを取る形式と、

command -file abc.txt

のようなオプションとオプション引数を取る形式に対応してます。

下記のようにコマンドオプションを、booleanの変数で受ける場合、

$ command -list

BoolVar()で変数にバインド出来ます。
第2引数にオプション名、第3引数でデフォルト値、第4引数でコメントを指定します。

var showList bool
flag.BoolVar(&showList, "list", false, "show list.")

最後にflag.Parse()を呼ぶとフラグが解析されます。

flag.Parse()

下記のようにオプションを文字列や数値で受けたい場合は、

$ command -f abc.txt
$ command -s 100

StringVar(), IntVar() を使用します。
これで、文字列や数値の変数にバインド出来ます。

var fileName string
flag.StringVar(&fileName, "f", "dummy.txt", "file name.")
var maxSize int
flag.IntVar(&maxSize, "s", 0, "max size.")


lsコマンドのように、ls -ltrのように複数のオプションをまとめて指定する方法を調べてみたんですが、
flagパッケージでは出来ないようでした。
(どうすれば出来るのか調べてみたけどよく分からん)

myls

lsコマンドを模したmylsコマンドを作ってみようと思います。
オプションは全部対応するのは出来ないので、下記3つのオプションだけ実装してみようと思います。

  • -l 詳細表示
  • -a 隠しファイル表示
  • -v バージョン表示

Windowsだとファイルのユーザやグループが取れないので、MacLinuxだけ対象にしようと思います。

ソースは下記の通り。
(golangに慣れてきたらリファクタリングして書き直したい)

main.go

package main

import (
	"flag"
	"fmt"
	"log"
	"os"
	"os/user"
	"strings"
	"syscall"
)

// -l format long
// -a show all
// -v version
// -h help
const (
	version   = "1.0"
	codeOk    = 0
	codeError = 1
)

func main() {
	os.Exit(executeCommand())
}

func executeCommand() int {
	// parse args option
	var showVersion bool
	flag.BoolVar(&showVersion, "v", false, "show version.")
	var showDetail bool
	flag.BoolVar(&showDetail, "l", false, "show list with more detail.")
	var showAll bool
	flag.BoolVar(&showAll, "a", false, "show all files.")
	flag.Parse()

	if showVersion {
		fmt.Println("version: " + version)
		return codeOk
	}

	// dir name
	dirName := flag.Arg(0)
	if dirName == "" {
		dirName = "."
	}

	f, err := os.Open(dirName)
	if err != nil {
		log.Fatal("Cannot open dir. Please input correct dir name:")
	}
	defer f.Close()

	fis, err := f.Readdir(0)
	if err != nil {
		log.Fatal("Cannot open dir. Please input correct dir name:")
	}

	if showDetail {
		longFormat(fis, showAll)
		return codeOk
	}
	ls(fis, showAll)
	return codeOk
}

func ls(fis []os.FileInfo, showAll bool) {
	for _, fi := range fis {
		filename := fi.Name()
		if !showAll && strings.HasPrefix(filename, ".") {
			// ignore dot files
			continue
		}
		fmt.Print(filename + " ")
	}
	fmt.Println("")
}

func longFormat(fis []os.FileInfo, showAll bool) {
	var stringFileInfos []*stringFileInfo

	for _, fi := range fis {
		info := stringFileInfo{}

		filename := fi.Name()
		if !showAll && strings.HasPrefix(filename, ".") {
			// ignore dot files
			continue
		}

		// file name
		info.name = filename
		// permission
		info.permission = fmt.Sprintf("%v", fi.Mode())
		// file size
		info.size = fmt.Sprintf("%v", fi.Size())
		// user name
		var s syscall.Stat_t
		syscall.Stat(fi.Name(), &s)
		u, _ := user.LookupId(fmt.Sprintf("%v", s.Uid))
		info.user = u.Username
		// group name
		g, _ := user.LookupGroupId(fmt.Sprintf("%v", s.Gid))
		info.group = g.Name
		// mod time
		modTime := fi.ModTime()
		info.modDateTime = modTime.Format("1 2 15:04")

		stringFileInfos = append(stringFileInfos, &info)
	}

	fillSpaces(stringFileInfos)
}

// fill spaces for format
func fillSpaces(infos []*stringFileInfo) {
	var maxLengthName int
	var maxLengthPerm int
	var maxLengthSize int
	var maxLengthUser int
	var maxLengthGroup int
	var maxLengthMonth int
	var maxLengthDay int
	var maxLengthTime int
	for _, info := range infos {
		if maxLengthName < len(info.name) {
			maxLengthName = len(info.name)
		}
		if maxLengthPerm < len(info.permission) {
			maxLengthPerm = len(info.permission)
		}
		if maxLengthSize < len(info.size) {
			maxLengthSize = len(info.size)
		}
		if maxLengthUser < len(info.user) {
			maxLengthUser = len(info.user)
		}
		if maxLengthGroup < len(info.group) {
			maxLengthGroup = len(info.group)
		}
		// split "M D hh:mi"
		dateTime := strings.Split(info.modDateTime, " ")
		if maxLengthMonth < len(dateTime[0]) {
			maxLengthMonth = len(dateTime[0])
		}
		if maxLengthDay < len(dateTime[1]) {
			maxLengthDay = len(dateTime[1])
		}
		if maxLengthTime < len(dateTime[2]) {
			maxLengthTime = len(dateTime[2])
		}
	}
	var spaceSize int
	for _, info := range infos {
		// name
		spaceSize = maxLengthName - len(info.name)
		info.name += strings.Repeat(" ", spaceSize)
		// size
		spaceSize = maxLengthSize - len(info.size)
		info.size += strings.Repeat(" ", spaceSize)
		// user name
		spaceSize = maxLengthUser - len(info.user)
		info.user += strings.Repeat(" ", spaceSize)
		// group name
		spaceSize = maxLengthGroup - len(info.group)
		info.group += strings.Repeat(" ", spaceSize)
		// mod date time (month + day + time)
		dateTime := strings.Split(info.modDateTime, " ")
		spaceSize = maxLengthMonth - len(dateTime[0]) // month
		info.modDateTime = dateTime[0] + strings.Repeat(" ", spaceSize+1)
		spaceSize = maxLengthDay - len(dateTime[1]) // day
		info.modDateTime += dateTime[1] + strings.Repeat(" ", spaceSize+1)
		spaceSize = maxLengthTime - len(dateTime[2]) // time
		info.modDateTime += dateTime[2] + strings.Repeat(" ", spaceSize)
	}
}

type stringFileInfo struct {
	name        string
	permission  string
	size        string
	user        string
	group       string
	modDateTime string
}

go build

Mac用バイナリ

go buildで各プラットフォーム用のバイナリを生成します。
Mac用のバイナリを生成してみます。

どのプラットフォーム用にビルドするかは環境変数のGOARCHとGOOSで指定します。
Macの場合、GOOSでdarwinを指定し、GOARCHでamd64のCPUを指定します。

環境変数確認

$ go env
GOARCH="amd64"
GOOS="darwin"

-oオプションで出力するファイル名を指定します。

$ go build -o myls main.go

または、下記の様に一度に指定することが可能。

$ GOOS=darwin GOARCH=amd64 go build -o myls main.go

バイナリが生成されました。

$ ll
-rwxr-xr-x  1 purple  staff   2.0M  6 13 20:49 myls

どこでも実行できるようにzshrcでPATHに通しておきます。

$ vi .zshrc
export PATH=$PATH:path_to_binary/myls
$ source .zshrc

確認

普通のlsコマンドの結果とほぼ同じになることを確認してみます。
lsの結果を確認。

$ ls -la
total 24
drwxr-xr-x   8 purple  staff  272  6 14 21:47 .
drwxr-xr-x  17 purple  staff  578  7  9 17:29 ..
-rw-r--r--   1 purple  staff   52  6 14 21:46 .vimrc
-rw-r--r--   1 purple  staff   97  5  1 08:03 HelloWorld.java
-rw-r--r--   1 purple  admin   19  5 21 08:03 abc.txt
drwxr-xr-x   2 purple  staff   68 12 23  2017 dir1
drwxr-xr-x   2 purple  staff   68 11 11  2017 dir2
-rwxrwxrwx   1 purple  staff    0  6 11 08:03 launcher

mylsをオプションなしで実行。

$ myls
abc.txt dir1 dir2 HelloWorld.java launcher

-aオプション
-aで隠しファイル表示。

$ myls -a
.vimrc abc.txt dir1 dir2 HelloWorld.java launcher

-lオプション
-lで詳細表示。

$ myls -l
-rw-r--r-- purple admin 19 5  21 08:03 abc.txt
drwxr-xr-x purple staff 68 12 23 08:03 dir1
drwxr-xr-x purple staff 68 11 11 08:03 dir2
-rw-r--r-- purple staff 97 5  1  08:03 HelloWorld.java
-rwxrwxrwx purple staff 0  6  11 08:03 launcher

複合オプション(-l, -a)

$ myls -l -a
-rw-r--r-- purple staff 52 6  14 21:46 .vimrc
-rw-r--r-- purple admin 19 5  21 08:03 abc.txt
drwxr-xr-x purple staff 68 12 23 08:03 dir1
drwxr-xr-x purple staff 68 11 11 08:03 dir2
-rw-r--r-- purple staff 97 5  1  08:03 HelloWorld.java
-rwxrwxrwx purple staff 0  6  11 08:03 launcher

-vオプション
-vでコマンドのバージョンを表示。

$ myls -v
version: 1.0

-hオプション
-hでヘルプを表示。

$ myls -h
Usage of myls:
  -a	show all files.
  -l	show list with more detail.
  -v	show version.

不明なオプション
不明なオプションが指定された場合は、ヘルプと同じ表示がされます。

$ myls -d
flag provided but not defined: -d
Usage of myls:
  -a	show all files.
  -l	show list with more detail.
  -v	show version.
Linux用バイナリ

Linux用のバイナリを生成してみます。
Linuxの場合、GOOSでlinuxを指定し、GOARCHでamd64のCPUを指定します。

$ GOOS=linux GOARCH=amd64 go build -o myls main.go

Linuxでも問題なく動きました。
確認結果はMac用とほぼ同じなので省略。


終わり。

ソースは下記にあげました。

github.com