※この記事は、CyberAgent PTA Advent Calendar 2020の24日目の記事です。
株式会社AbemaTV ビジネス開発本部 で広告システムのエンジニアをしています @shunta-furukawa です。
今日はクリスマスイブですね。メリークリスマスイブ!!
はじめに
さて、AJA SSPとその技術について、でも触れられていましたが、サイバーエージェントで作られるシステムで Go言語が用いられることは多いです。
Go言語は、他の言語と比べて 言語仕様がシンプルなためハイパフォーマンスを出しつつも 扱いやすいために人気がある言語だと思います。反面、抽象化された賢い記法などがなく、記述量が多い言語でもあると思います。
- 書いていると気付いたらもう夜に...
- どうにか実装スピードをあげたい...
そんな時に、go generate
と Mustache
と YAML
を使って メタプログラミング的に 実装をする形にしたらある程度効率化されたので、その方法を紹介できればと思います。
※ 今回の記事用に、簡単なサンプルコードを用意したので合わせてご確認ください。
Go言語 で メタプログラミング
Go言語 をたくさん書くと 変更に弱くなる
Go言語で実装を進めていくと、同じような構造のコードを書くことがよくあります。
ある程度の量コードを書いたあとに、途中で仕様に変更があった場合、影響箇所が散らばっていていると
同じ変更なのにもかかわらず、変更量も多くなってゲンナリします。
似たような例で、「筋肉によるGoコードジェネレーション」 が参考になったので紹介をさせていただきます。 go-slack
に変更を加えようとしたさいに、以下のような話がありました。
github.com/nlopes/slackにPR送ったりしていたら、ひとつの仕組みを直すのに20ファイルを手動で変更する 必要があった
このように、一つの変更なのにもかかわらず、コードの変更量が多くなるケースはよくあります。
抽象化したデータからコード生成して変更に強くする
こう言った変更に強くするためには、コードをうまく抽象化したものと、抽象化されたものから実際のコードへの変換ルール記述してあげることが有効です。 こうすることで、抽象化された定義だけを変更することで、少ない変更量のまま、変更分をコード全体に波及させることができます。 この手法は いわゆるメタプログラミング と呼ばれるものです。
メタプログラミング (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
)がいる。 - 子供たちはそれぞれ欲しいプレゼントの条件がある。
- どの子供がどのプレゼントを欲しいのかが出力される。
(コード生成の恩恵を感じるためには、ある程度の規模が必要だったのでシンプルなサンプルでないことご容赦ください。)
実際のコードはこちら
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")
}
-
NewSportsCar
はSportsCar
を返却し、SportsCar
はGift
interface を実装します。 - 同様に、
NewTaro
はTaro
を返却し、Taro
はKid
interface を実装します。
出力例がこちら :
=======================================
===【:*・゚☆† Merry Ⅹ’mas †.。.:*・゚】===
=======================================
よういした プレゼント :
- スポーツカー【のりもの|あか|おとこのこ向け】
☆★☆★ たろうくん (4) ★☆★☆
ほしいもの :
あか の のりもの が ほしい
もらえるおもちゃ:
スポーツカー【のりもの|あか|おとこのこ向け】
!!!メリークリスマス!!!
今回は、この SportsCar
や Taro
の実装を YAML で記述したデータを元に 生成し、
YAML を変更することでバリエーションを増やしても狙ったように動くことを目指します。
以下生成したい目標のコードです。
SportsCar の コード
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 のコード
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 自体はこちらを参考に:
例えば SportsCar の場合は 以下のような部分を拾ってきました。
gifts:
- name: SportsCar
jname: スポーツカー
category: のりもの
color: あか
gender: おとこのこ
Mustache の 準備
Mustache は テンプレート言語の一種です。 今回はGo言語の生成に用いますが、他の言語でも 実装が沢山あります。
参考 :
{{ }}
この二重中括弧でくくる記法が特徴的で、口髭に似ていることから Mustache と呼ばれているようです。
さて、Mustacheで、先ほどの YAML のデータを流し込む テンプレートを書きます。
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ファイルを作成します。
大まかな流れは、以下の通りです。
- YAML を Unmarshal して Go の構造体にする
- Mustache へ 流し込む
- 流し込んだ結果をファイルとして出力する
です。
以下に、実際のコードの一部を記載します
1. YAML を Unmarshal して Go の構造体にする
yaml の構造にあった struct を事前に定義しておきます。
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します
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. 流し込んだ結果をファイルとして出力する
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:
- 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:
- 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
では、!!!メリークリスマス!!!