YAML+Mustache+go-generate で go の メタプログラミング をする


※この記事は、CyberAgent PTA Advent Calendar 2020の24日目の記事です。

株式会社AbemaTV ビジネス開発本部 で広告システムのエンジニアをしています @shunta-furukawa です。
今日はクリスマスイブですね。メリークリスマスイブ!!

はじめに

さて、AJA SSPとその技術について、でも触れられていましたが、サイバーエージェントで作られるシステムで Go言語が用いられることは多いです。

Go言語は、他の言語と比べて 言語仕様がシンプルなためハイパフォーマンスを出しつつも 扱いやすいために人気がある言語だと思います。反面、抽象化された賢い記法などがなく、記述量が多い言語でもあると思います。

  • 書いていると気付いたらもう夜に...
  • どうにか実装スピードをあげたい...

そんな時に、go generateMustacheYAML を使って メタプログラミング的に 実装をする形にしたらある程度効率化されたので、その方法を紹介できればと思います。

※ 今回の記事用に、簡単なサンプルコードを用意したので合わせてご確認ください。

Go言語 で メタプログラミング

Go言語 をたくさん書くと 変更に弱くなる

Go言語で実装を進めていくと、同じような構造のコードを書くことがよくあります。
ある程度の量コードを書いたあとに、途中で仕様に変更があった場合、影響箇所が散らばっていていると
同じ変更なのにもかかわらず、変更量も多くなってゲンナリします。

似たような例で、「筋肉によるGoコードジェネレーション」 が参考になったので紹介をさせていただきます。 go-slackに変更を加えようとしたさいに、以下のような話がありました。

github.com/nlopes/slackにPR送ったりしていたら、ひとつの仕組みを直すのに20ファイルを手動で変更する 必要があった

このように、一つの変更なのにもかかわらず、コードの変更量が多くなるケースはよくあります。

抽象化したデータからコード生成して変更に強くする

こう言った変更に強くするためには、コードをうまく抽象化したものと、抽象化されたものから実際のコードへの変換ルール記述してあげることが有効です。 こうすることで、抽象化された定義だけを変更することで、少ない変更量のまま、変更分をコード全体に波及させることができます。 この手法は いわゆるメタプログラミング と呼ばれるものです。

メタプログラミング - wikipedia

メタプログラミング (metaprogramming) とはプログラミング技法の一種で、ロジックを直接コーディングするのではなく、あるパターンをもったロジックを生成する高位ロジックによってプログラミングを行う方法、またその高位ロジックを定義する方法のこと。

先ほどの 筋肉によるGoコードジェネレーション の例でも、endpoint.json というファイルからコード生成を行うことで、変更があってもこのjsonファイルを修正すればいいという状態を作っています。これは、言葉は違えど メタプログラミングをしていると言えます。

Go言語 の go generate

Go言語では あらかじめ goのコードを生成する go generate という仕組みが 提供されています。

go generate は、go generate [PATH] を実行すると、引数で渡したパスの中にある goファイルの中から //go:generate ~ というコメントが書かれているファイルと抽出し、このコメントの後に書いてあるコマンドを実行してくれる仕組みです。

自動生成部分を実装して、このコメントを書けば go generate ./... を実行するだけでコードの生成が完了します。

今回は、この go generate から go を呼び出し、コード生成をしていきます。
go generate の詳かいことに関しては、下記の記事などが参考になります。

go generate の 参考

YAML と Mustache を 使って コード生成

ここからは、実際にコードを示しながら YAMLとMustache を使って go generate で実行できる コードジェネレータを作っていこうと思います。

実際のコードはこちらです。

前提 (実現をしたいこと)

クリスマスなので、以下のようなコンセプトのサンプルコードを用意しました。

  • サンタが用意したいろいろなプレゼント(Gift) が サンタ袋 (sack) の中に入っている。
  • サンタのプレゼントを待っている子供たち(kids)がいる。
  • 子供たちはそれぞれ欲しいプレゼントの条件がある。
  • どの子供がどのプレゼントを欲しいのかが出力される。

(コード生成の恩恵を感じるためには、ある程度の規模が必要だったのでシンプルなサンプルでないことご容赦ください。)

実際のコードはこちら

main.go
package main

import (
    "fmt"
    "strings"

    "../app/gift"
    "../app/kid"
)

func main() {

    // サンタクロースの袋の中
    sack := make([]gift.Gift, 0)

    // サンタクロースの袋の中身を詰める
    sack = append(sack, gift.NewSportsCar())

    // 子供たち
    kids := make([]kid.Kid, 0)

    // サンタクロースの袋の中身を詰める
    kids = append(kids, kid.NewTaro())

    // 子供たちへのプレゼント を 表示
    giftlist := make([]string, 0)
    for _, gift := range sack {
        giftlist = append(giftlist, gift.Display())
    }

    fmt.Printf("=======================================\n===【:*・゚☆† Merry Ⅹ’mas †.。.:*・゚】=== \n=======================================\n\nよういした プレゼント : \n  - %s\n\n", strings.Join(giftlist, "\n  - "))

    for _, kid := range kids {
        fmt.Printf("%s\nほしいもの : \n  %s\nもらえるおもちゃ: \n  %s \n",
            kid.Display(),
            kid.Wishlist(),
            kid.CanGet(sack),
        )
    }

    fmt.Printf("\n !!!メリークリスマス!!!\n")

}

  • NewSportsCarSportsCar を返却し、SportsCarGift interface を実装します。
  • 同様に、NewTaroTaro を返却し、TaroKid interface を実装します。

出力例がこちら :

=======================================
===【:*・゚☆† Merry Ⅹ’mas †.。.:*・゚】=== 
=======================================

よういした プレゼント : 
  - スポーツカー【のりもの|あか|おとこのこ向け】

☆★☆★ たろうくん (4) ★☆★☆
ほしいもの : 
  あか の のりもの が ほしい
もらえるおもちゃ: 
  スポーツカー【のりもの|あか|おとこのこ向け】 

 !!!メリークリスマス!!!

今回は、この SportsCarTaro の実装を YAML で記述したデータを元に 生成し、
YAML を変更することでバリエーションを増やしても狙ったように動くことを目指します。

以下生成したい目標のコードです。

SportsCar の コード

sportscar.go
package gift

import "fmt"

// SportsCar represents SportCar.
type SportsCar struct {
    Name     string
    Category string
    Color    string
    Gender   string
}

// NewSportsCar returns new SportCar.
func NewSportsCar() SportsCar {
    return SportsCar{
        Name:     "スポーツカー",
        Category: "のりもの",
        Color:    "あか",
        Gender:   "おとこのこ",
    }
}

// Display returns spec of SportCar.
func (g SportsCar) Display() string {
    return fmt.Sprintf(`%s【%s|%s|%s向け】`,
        g.Name,
        g.Category,
        g.Color,
        g.Gender,
    )
}

// GetName returns its name.
func (g SportsCar) GetName() string {
    return g.Name
}

// GetCategory returns its category.
func (g SportsCar) GetCategory() string {
    return g.Category
}

// GetGender returns its gender.
func (g SportsCar) GetGender() string {
    return g.Gender
}

// GetColor returns its color.
func (g SportsCar) GetColor() string {
    return g.Color
}

Taro のコード

taro.go
package kid

import (
    "strings"

    "../gift"
)

// Taro represents Taro.
type Taro struct {
    Name   string
    Gender string
    Age    int
}

// NewTaro returns instance of Kids Impl as Taro
func NewTaro() Taro {
    return Taro{
        Name:   "たろう",
        Gender: "おとこのこ",
        Age:    4,
    }
}

// Display returns name of the kid.
func (k Taro) Display() string {
    return "☆★☆★ たろうくん (4) ★☆★☆"
}

// Wishlist returns the kid's wishlist.
func (k Taro) Wishlist() string {
    return "あか の のりもの がほしい"
}

// CanGet returns gift the kid can get.
func (k Taro) CanGet(sack []gift.Gift) string {
    gds := make([]string, 0)

    for _, gift := range sack {
        if gift.GetColor() == "あか" && gift.GetCategory() == "のりもの" {
            gds = append(gds, gift.Display())
        }
    }

    if len(gds) == 0 {
        return "ほしいものがみつからなかった..."
    }
    return strings.Join(gds, "\n  ")
}

YAML の 準備

上記のコードを生成するために、共通かできる部分と 個体ごと(Gift/Kid) ごとに違う情報を分けて考え、
個体後ごとに違ってくる部分を 構造化して YAML に定義します。

YAML 自体はこちらを参考に:
- YAML - wikipedia
- go-yaml

例えば SportsCar の場合は 以下のような部分を拾ってきました。

gifts.yml
gifts: 
  - name: SportsCar
    jname: スポーツカー
    category: のりもの
    color: あか
    gender: おとこのこ

Mustache の 準備

Mustache は テンプレート言語の一種です。 今回はGo言語の生成に用いますが、他の言語でも 実装が沢山あります。

参考 :
- Mustache 公式
- Mustache Template Engine for Go

{{ }} この二重中括弧でくくる記法が特徴的で、口髭に似ていることから Mustache と呼ばれているようです。

さて、Mustacheで、先ほどの YAML のデータを流し込む テンプレートを書きます。

gift.mustache
package gift

import "fmt"

// {{Name}} represents SportCar.
type {{Name}} struct {
    Name     string
    Category string
    Color    string
    Gender   string
}

// New{{Name}} returns new SportCar.
func New{{Name}}() {{Name}} {
    return {{Name}}{
        Name:     "{{JName}}",
        Category: "{{Category}}",
        Color:    "{{Color}}",
        Gender:   "{{Gender}}",
    }
}

// Display returns spec of SportCar.
func (g {{Name}}) Display() string {
    return fmt.Sprintf(`%s【%s|%s|%s向け】`,
        g.Name,
        g.Category,
        g.Color,
        g.Gender,
    )
}

// GetName returns its name.
func (g {{Name}}) GetName() string {
    return g.Name
}

// GetCategory returns its category.
func (g {{Name}}) GetCategory() string {
    return g.Category
}

// GetGender returns its gender.
func (g {{Name}}) GetGender() string {
    return g.Gender
}

// GetColor returns its color.
func (g {{Name}}) GetColor() string {
    return g.Color
}

Go言語で Mustache を 扱う場合には、Goの構造体にアクセスできる必要があるため、
{{}} の中身は 大文字で書く必要があります。

コード生成する go のプログラムを書く

YAML と Mustache が準備できたら、最終的にコードを生成する goファイルを作成します。

大まかな流れは、以下の通りです。

  1. YAML を Unmarshal して Go の構造体にする
  2. Mustache へ 流し込む
  3. 流し込んだ結果をファイルとして出力する

です。

以下に、実際のコードの一部を記載します

1. YAML を Unmarshal して Go の構造体にする

yaml の構造にあった struct を事前に定義しておきます。

gift.go
package model

type (
    // GiftContainer wrap interfaces
    GiftContainer struct {
        Gifts []Gift `yaml:"gifts"`
    }

    // Gift represents kid
    Gift struct {
        Name     string `yaml:"name"`
        JName    string `yaml:"jname"`
        Category string `yaml:"category"`
        Color    string `yaml:"color"`
        Gender   string `yaml:"gender"`
    }
)

そして、yamlファイルを指定して、この構造体へUnmarshalします

main.go
package main

import (...)
... 

//go:generate go run main.go
func main() {

    // Kid 生成 ... 省略

    // Gift 生成
    giftBuf, err := ioutil.ReadFile(giftsInputPath)
    if !errors.Is(err, nil) {
        panic(err)
    }
    giftContainer := model.GiftContainer{}
    giftContainer.Gifts = make([]model.Gift, 0)
    err = yaml.Unmarshal(giftBuf, &giftContainer)
    if !errors.Is(err, nil) {
        panic(err)
    }
    generateGifts(giftContainer.Gifts)

        // ... 省略
}

2. Mustache へ 流し込む -> 3. 流し込んだ結果をファイルとして出力する

main.go
func generateGifts(gifts []model.Gift) {
    // テンプレートの読み込み
    giftTemplate, err := mustache.ParseFile(giftTemplatePath)
    if !errors.Is(err, nil) {
        panic(err)
    }
    // Gifts からの 書き出し
    for _, p := range gifts {
        // テンプレートが増えた時に、ここの要素を増やすと拡張できる。
        for _, r := range []Renderer{
            {
                Tmpl: giftTemplate,
                Path: giftOutputPath,
            },
        } {
            outputFile(r, p.Name, p)
        }
    }
}

func outputFile(r Renderer, name string, data interface{}) {
    output, err := r.Tmpl.Render(data)
    if !errors.Is(err, nil) {
        panic(err)
    }

    outputBytes, err := format.Source([]byte(output))
    if !errors.Is(err, nil) {
        panic(err)
    }
    // outputBytes := []byte(output)

    // ディレクトリを作成、存在する場合は無視する。
    _ = os.MkdirAll(r.Path, 0755)

    // []byte をファイルに上書きしています。
    filename := r.Path + strings.ToLower(name) + r.Postfix + ".go"
    err = ioutil.WriteFile(filename, outputBytes, 0755)
    if err != nil {
        panic(err)
    }

    fmt.Printf("mustache: generate %s\n", filename)

}

ここまでできたら、メタプログラミングの準備は完了です。

( Kids の実装は 長くなるので、割愛します。 リポジトリを是非 確認してみてください )

実際にコード生成

ここまでできたら、一番初めに書いたコードが コードジェネレータでも吐き出されるかを確かめます。

実は、main.go に 以下のコメント行を追加してあります。

//go:generate go run main.go

これを書いておくことによって generate コマンドを実行すると main.go が実行され、
コードが生成されます。

go generate ./... 

これで、差分が出てこなかったら完成です。

YAML を増やして拡張する

せっかくなので、YAML を増やすことによって
プレゼントや 子供 を増やして、賑やかにしてみます。

YAML を 書き直して コードを生成して実行してみます

gifts.yaml
gifts: 
  - name: SportsCar
    jname: スポーツカー
    category: のりもの
    color: あか
    gender: おとこのこ
  - name: GabageCollector
    jname: ゴミしゅうしゅうしゃ
    category: のりもの
    color: あお
    gender: おとこのこ
  - name: Sword
    jname: つるぎ
    category: ぶき
    color: あお
    gender: おとこのこ
  - name: Gun
    jname: けんじゅう
    category: ぶき
    color: くろ
    gender: おとこのこ
  - name: TeddyBear
    jname: くまのぬいぐるみ
    category: にんぎょう
    color: ちゃいろ
    gender: おんなのこ
  - name: BabyDoll
    jname: あかちゃんにんぎょう
    category: にんぎょう
    color: ぴんく
    gender: おんなのこ
  - name: PrincessDoll
    jname: にんぎょう
    category: にんぎょう
    color: ぴんく
    gender: おんなのこ
  - name: CatBulletTrain
    jname: ねこのしんかんせん
    category: のりもの
    color: ぴんく
    gender: おんなのこ
  - name: HeroToy 
    jname: ゆうしゃのフィギュア
    category: にんぎょう
    color: くろ
    gender: おとこのこ
kids.yml
kids: 
  - name: Taro
    jname: たろう
    gender: おとこのこ
    age: 4 
    preferences: 
      - attribute: Color 
        value: あか
      - attribute: Category 
        value: のりもの
  - name: Jiro
    jname: じろう
    gender: おとこのこ
    age: 5 
    preferences: 
      - attribute: Color 
        value: くろ
    genderBiased: true
  - name: Yuta
    jname: ゆうた
    gender: おとこのこ
    age: 7
    preferences: 
      - attribute: Name
        value: けんじゅう
  - name: Yuuko
    jname: ゆうこ
    gender: おんなのこ
    age: 10 
    preferences: 
      - attribute: Color 
        value: ぴんく
      - attribute: Gender
        value: おんなのこ
  - name: Hinako
    jname: ひなこ
    gender: おんなのこ
    age: 8
    preferences: 
      - attribute: Category 
        value: にんぎょう

この状態で、以下を実行します

go generate ./... 
go run cmd/main.go 

すると、たくさんのプレゼントと たくさんの子供たちが増えている様子がわかると思います

=======================================
===【:*・゚☆† Merry Ⅹ’mas †.。.:*・゚】=== 
=======================================

よういした プレゼント : 
  - スポーツカー【のりもの|あか|おとこのこ向け】
  - ゴミしゅうしゅうしゃ【のりもの|あお|おとこのこ向け】
  - つるぎ【ぶき|あお|おとこのこ向け】
  - けんじゅう【ぶき|くろ|おとこのこ向け】
  - くまのぬいぐるみ【にんぎょう|ちゃいろ|おんなのこ向け】
  - あかちゃんにんぎょう【にんぎょう|ぴんく|おんなのこ向け】
  - にんぎょう【にんぎょう|ぴんく|おんなのこ向け】
  - ねこのしんかんせん【のりもの|ぴんく|おんなのこ向け】
  - ゆうしゃのフィギュア【にんぎょう|くろ|おとこのこ向け】

☆★☆★ たろうくん (4) ★☆★☆
ほしいもの : 
  あか の のりもの がほしい
もらえるおもちゃ: 
  スポーツカー【のりもの|あか|おとこのこ向け】 

☆★☆★ じろうくん (5) ★☆★☆
ほしいもの : 
  くろ の もの がほしい
でも おとこのこ向けじゃなきゃいやだ。
もらえるおもちゃ: 
  けんじゅう【ぶき|くろ|おとこのこ向け】
  ゆうしゃのフィギュア【にんぎょう|くろ|おとこのこ向け】 

☆★☆★ ゆうたくん (7) ★☆★☆
ほしいもの : 
  けんじゅう がほしい
もらえるおもちゃ: 
  けんじゅう【ぶき|くろ|おとこのこ向け】 

☆★☆★ ゆうこちゃん (10) ★☆★☆
ほしいもの : 
  おんなのこ向け で ぴんく の もの がほしい
もらえるおもちゃ: 
  あかちゃんにんぎょう【にんぎょう|ぴんく|おんなのこ向け】
  にんぎょう【にんぎょう|ぴんく|おんなのこ向け】
  ねこのしんかんせん【のりもの|ぴんく|おんなのこ向け】 

☆★☆★ ひなこちゃん (8) ★☆★☆
ほしいもの : 
  にんぎょう がほしい
もらえるおもちゃ: 
  くまのぬいぐるみ【にんぎょう|ちゃいろ|おんなのこ向け】
  あかちゃんにんぎょう【にんぎょう|ぴんく|おんなのこ向け】
  にんぎょう【にんぎょう|ぴんく|おんなのこ向け】
  ゆうしゃのフィギュア【にんぎょう|くろ|おとこのこ向け】 


 !!!メリークリスマス!!!

さいごに

これまで、go言語の実装を Mustache と YAML をつかって、 メタプログラミングっぽく実装することをやりました。
この手法をうまく活用すると、 例えば 一部の変更があっても YAMLを変更するだけで すぐに全体に反映されるようになります。

うまく活用して 快適なプログラム生活が送れるといいですね!

明日は、最終日。yamaguchi_naoto さんの記事です!お楽しみに :D

では、!!!メリークリスマス!!!