はじめに
こちらはGo4 Advent Calendar 2019の19日目として書いております。
ギリギリ間に合った!!!
何を作ったか
gofmtmd というcliツールにinspireされて、tffmtmdというmarkdown内のhclソースコードをfmtするツールを作りました。
gofmtmd でのblackfridayというmarkdown processerのmoduleの使い方を見て、「ちょっといじれば、これGoだけじゃなくMarkdown内のなんのコードでもfmtできるな??」となり、勢い余ってパクってしまいました。
その際に、Goでのmd内のソースコードの扱い方、terraform fmtコマンドのソース(Goで書かれている)の構造やOSSツールをリリースする際のお作法について学べたので、そこらへんをまとめていこうかなと思います。
Goでのmd内のソースコードの扱い方
MarkdownのASTを扱う場合、blackfridayを使用して、*blackfriday.Node
のWalkメソッドにNodeVisitor
型を満たす関数を渡して再帰的に実行させることで実現するのが一番簡単そうです。
詳しくはアドベントカレンダーに向けてMarkdown に埋め込まれた Go のソースコードに gofmt をかけてくれるツールを作った - Qiitaをご参照ください。
以下のように、本家ではGoのCodeBlockを検知しているところを、HCLを検知してhclのsyntaxcheckをした後にformatをかけています。
func (i impleMdFile) hclFmtWalkerFunc(synerr *error) blackfriday.NodeVisitor {
return func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus {
if i.isHclCodeBlock(node) {
_, syntaxDiags := hclsyntax.ParseConfig(node.Literal, i.filename, hcl.Pos{Line: 1, Column: 1})
if syntaxDiags.HasErrors() {
*synerr = errors.New("[tffmtmd] failed to format hcl source code. Please check syntax")
return blackfriday.Terminate
}
result := hclwrite.Format(node.Literal)
*i.md = bytes.ReplaceAll(*i.md, bytes.TrimRight(node.Literal, "\n"), bytes.TrimRight(result, "\n"))
}
return blackfriday.GoToNext
}
}
terraform fmtコマンドのソース構造
terraform fmtってどんなソースで構成されているんやろ、みたいな知的好奇心がツール作成のモチベーションの大きな部分だったので読んでみました。
func (c *FmtCommand) fmt(paths []string, stdin io.Reader, stdout io.Writer) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if len(paths) == 0 { // Assuming stdin, then.
if c.write {
diags = diags.Append(fmt.Errorf("Option -write cannot be used when reading from stdin"))
return diags
}
fileDiags := c.processFile("<stdin>", stdin, stdout, true)
diags = diags.Append(fileDiags)
return diags
}
for _, path := range paths {
path = c.normalizePath(path)
info, err := os.Stat(path)
if err != nil {
diags = diags.Append(fmt.Errorf("No file or directory at %s", path))
return diags
}
if info.IsDir() {
dirDiags := c.processDir(path, stdout)
diags = diags.Append(dirDiags)
} else {
switch filepath.Ext(path) {
case ".tf", ".tfvars":
f, err := os.Open(path)
if err != nil {
// Open does not produce error messages that are end-user-appropriate,
// so we'll need to simplify here.
diags = diags.Append(fmt.Errorf("Failed to read file %s", path))
continue
}
fileDiags := c.processFile(c.normalizePath(path), f, stdout, false) //❶
diags = diags.Append(fileDiags)
f.Close()
default:
diags = diags.Append(fmt.Errorf("Only .tf and .tfvars files can be processed with terraform fmt"))
continue
}
}
}
return diags
}
とってもシンプルな作りですね。
なるほど、.tfと.tfvarsのファイルの時だけ❶でc.processFile
メソッドの中でごにょごにょやっている感じやな、c.processFile
を読みに行きましょか(dirだったらc.processDir
の中で同じようなことをやっている)
func (c *FmtCommand) processFile(path string, r io.Reader, w io.Writer, isStdout bool) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
log.Printf("[TRACE] terraform fmt: Formatting %s", path)
src, err := ioutil.ReadAll(r)
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to read %s", path))
return diags
}
// File must be parseable as HCL native syntax before we'll try to format
// it. If not, the formatter is likely to make drastic changes that would
// be hard for the user to undo.
_, syntaxDiags := hclsyntax.ParseConfig(src, path, hcl.Pos{Line: 1, Column: 1}) //❷
if syntaxDiags.HasErrors() {
diags = diags.Append(syntaxDiags)
return diags
}
result := hclwrite.Format(src) //❸
if !bytes.Equal(src, result) {
// Something was changed
if c.list {
fmt.Fprintln(w, path)
}
if c.write {
err := ioutil.WriteFile(path, result, 0644)
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to write %s", path))
return diags
}
}
if c.diff {
diff, err := bytesDiff(src, result, path)
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to generate diff for %s: %s", path, err))
return diags
}
w.Write(diff)
}
}
if !c.list && !c.write && !c.diff {
_, err = w.Write(result)
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to write result"))
}
}
return diags
}
あら、❸のhclwrite.Format
しているだけかと思ったら、まず❷でhclsyntaxのチェックをしているのか。
syntax check前のコメントを読んでみる。
File must be parseable as HCL native syntax before we'll try to format
it. If not, the formatter is likely to make drastic changes that would
be hard for the user to undo.
ファイルはHCLのネイティブシンタックスとしてパースできるように、formatする前になっているべきで、やらないとformatterは元にユーザーが戻しづらい劇的な変化を起こしちゃう。
なるほど I understand.
ということでsyntax checkもformatもtffmtmdの処理に入れ込むことにしました。
GoのOSSツールをリリースする際のお作法
1. TableDrivenTestsを書き、Test Coverageを出しておく
GoはTableDrivenTestsで書いていくのが良いというのは、Gopher道場(卒業レポ: #5 Gopher道場を卒業しました - Qiita)で教わってから現場で実践はしてきましたが、自分の開発したツールに書いていくのは初めてでした。
可読性も上がるし、テストも追加しやすいのでこれはマストでやったほうが良いと思います。
その際、gotestsなんかで雛形をgenerateして、そこから作っていくと楽にかけるかなと思います。(jetbrains社製のIDEならデフォルトでついている)
また、ユーザーにとってTest Coverageが一目で見えることで、ツールとして安心して選びやすくなると思います。codecov.ioなんかを使用して、カバレッジをバッジ画像でREADMEにつけておくのが良さそうです。(これもgofmtmdからの受け売り。その他にGolangCIやCircleCIやgodocのバッジをつけておくととても可愛いし、ユーザーが安心して選びやすいと思う。)
設定方法の詳細はこちらを参考にしてください。 -> CircleCIとCodecovでGitHubにカバレッジバッジをつけよう! - VELTRA Engineering - Medium
2. goreleaserで各プラットフォームへのバイナリを自動リリースする
goreleaserは、バイナリのクロスコンパイルと Github Releases へのデプロイ自動化ツールです。
直近のtagに対して、goreleaser
コマンドを打つだけで.goreleaser.ymlファイルで指定したOSや環境に対応したバイナリをリリースしてくれます。(githubなら、tagではなく最新のコミットに紐付ける--snapshotふらぐもある)
なんて便利なんだ。。。。
詳しい設定方法は公式チュートリアルをご参照ください!
最後に
ほぼほぼgofmtmdの受け売りだし簡易なツール作成でしたが、ほぼ初めてのパブリックな自家製ツールだったので、非常に勉強になることが多かったです。
あとはお仕事ではBitbucketを使っていることが多い(たまにGitlab)ので、久々にGithub使うと周辺ツールも相まって快適すぎてやばい。。
今回の記事では、本当はterraformソースからUMLをジェネるツールか、terraform-qiita-providerをGoで書く話を書くつもりでしたが、諸所ゴタゴタしている関係で今日までに完成させることができませんでした。。
それらのツールが完成したら、また諸々まとめてアウトプットしようと思っています。その際にも今回学べたことが大きく活きる気がしています。