Media Do Tech Do Blog

株式会社メディアドゥのエンジニアによるブログです。

テックブログのpv数ランキングをアイドルが通知してくれるslack botをLambda + Goで作った話

f:id:qazx7412:20191128185155p:plain

この記事はMedia Do Advent Calendar 2019、6日目の記事です。

こんにちは、エンジニアの回路(@qazx7412)です。
今年の5月頭に縁がありjoinしてからもう半年くらいたってしまったのかと時の流れの速さを感じる今日このごろです。

今回は私が参加しているブログ運営チームでのデータの可視化と執筆者のモチベ向上のための施策として制作したslack botの話をさせていただこうと思います。

現在メディアドゥではエンジニア採用の強化のために、このテックブログの運営や自社での勉強会の運営など採用広報に関する活動をエンジニアやデザイナーなどの技術職が主体となって行っています。
私も入社してから参加を打診され(一応当然ですが強制参加ではないです)、ブログ運営チームへ参加することになりました。
ブログチームでは、ちょうど収集したブログのデータの可視化や執筆者を増やすために執筆のモチベーションを上げるための施策をしようという話になっており、そこで私が前職のslackにあったブログのpv数ランキングを通知してくれるbotの話を軽率にしたらチーム内での反応も良かったので作ってしまおうということになりました。

ところでなのですが、私は個人でslackのワークスペースを運用しています。
大体のチャンネルはRSSやTwitterをそのままつなげているだけなのですが、それでは対応できないGithHubの通知などに対応するために空いた時間でちまちまとapiから情報を取ってきてくれるslack botを自作したりしています。
ただ普通にslack botを作っても何も面白くないしモチベも上がらないのでこんな感じで某大人気有名アイドルさんにプルリクの通知を教えてくれるようにしています。

f:id:qazx7412:20191128185626p:plain
web媒体に出てくるアイドルの画像はグレーに切り取られているのが当然。いいね?

アイドルが毎日ランキングを通知してくれたら嬉しくないですか?私は嬉しいです。
というわけで個人slack向けにアイドルがqiitaのランキングを通知してくれるbotを作ってついでに同じものを会社のslackにも導入することにしました。

さて前置きはこれくらいにして早速実装の話に入って行きたいと思います。
まず最終的な完成品はこちらにおいてあります。

github.com

構成としてはこんな感じです。

f:id:qazx7412:20191128185740p:plain

まあ正直に言って実にありがちなやつだと思います。
このテックブログははてなブログにて運用されており、Google Analyticsを使ってデータを収集しています。
それをLambdaが日時で動いて集計をしてslackへ投稿してくれるという寸法です。

Lambdaの管理にはServerless Framework(以下Serverless)を利用しました。

serverless.com

採用理由はServerlessの使いやすさもあるのですが、私が前職や今までの個人開発で経験があったというのが大きいです。
Serverlessは serverless.yml というファイルにLambdaファンクションへ紐付けるhandlerと、それがapiやjobとしてどのように動くかを記述します。
今回は下記のように bin/handler というhandlerを毎日UTCの10時(JSTで19時)に動かしています。

# serverless.yml

functions:
  analyticsNotificationsSlack:
    handler: bin/handler
    events:
      - schedule: cron(0 10 * * ? *) # UTC

次にLambdaで動かす言語ですが、今回はGoを使うことにしました。

go.dev

普段個人開発でLambdaを使うときはCrystalDartを使っているのですが、現在開発に関わっている配信システムのアーキテクチャに理解を深めたいなと思ったので今回はGoを使ってみることにしました。
ただとは言いつつ色々サボってしまってはいます。
たとえば本来テストを書くためにはusecase内で直接repositoryを呼んだりするのは良くないのですが呼んでしまっていたり発生したエラーをなんの工夫もなくreturnしてしまっていたりそもそもテストを書いていなかったりetc…

ところでGoと触れ合って見た感想ですが、まずインフラ面がめちゃくちゃ良くできてると思います。
とくにLambdaみたいなローカルで頑張って固めてデプロイしなきゃいけない環境だとよくわかるのですが、比較的簡単にクロスコンパイルできてできるだけ依存性のないシングルバイナリができるのでどこにでも持ち運びやすいのは他の言語に比べて圧倒的に楽です。
またVS Codeの拡張の圧倒的書きやすさも素晴らしいと思います。
それから文法面でもstructという概念はデータ、とくにjsonの取り回しがとても楽だなと感じました。
ただ一方でやはり三項演算子やif式みたいな一行で分岐をかける仕組みがないのはたまに息苦しく感じるときがあります。
またやはりforで全部解決するのではなくmapやfilterとかは欲しくなるときがありますね。

閑話休題。
ディレクトリ構成の戦略は配信システムを参考にしてrepositoryに外部へのアクセスのためのコード、usecaseにはビジネスロジックという形にしてみました。
また、Lambdaの実行の起点になるHandlerはhandlerというディレクトリに入れてあります。

// handler/main.go

func Handler(ctx context.Context) (Response, error) {
    app := usecase.NewNotifyUsecase()
    err := app.Run()
    if err != nil {
        app.Error(err)
    }

    return Response{}, nil
}

func main() {
    lambda.Start(Handler)
}

Google Analyticsからはpv数の一覧を取るコードはこんな感じです。
apiにアクセスするためには、これだけではなく環境変数 GOOGLE_APPLICATION_CREDENTIALS にjson形式のクレデンシャルを指定する必要があります。
公式のGoのライブラリから取得できる結果は2次元配列になっていて使いにくいので適当に用意したstructに変換しています。

// repository/analytics.go

type Page struct {
    Title string
    Path  string
    PV    string
}

func (a *analyticsImpl) getService() (*analytics.Service, error) {
    ctx := context.Background()
    analyticsService, err := analytics.NewService(ctx)
    if err != nil {
        return nil, err
    }

    return analyticsService, nil
}

func (a *analyticsImpl) GetSessions(start string, end string) ([]*Page, error) {
    service, err := a.getService()
    if err != nil {
        return nil, err
    }

    data, err := service.Data.Ga.
        Get("ga:"+os.Getenv("PROFILE_ID"), start, end, "ga:sessions").
        Dimensions("ga:pageTitle,ga:hostname,ga:pagePath").
        Do()
    if err != nil {
        return nil, err
    }

    result := []*Page{}
    for _, line := range data.Rows {
        if strings.Count(line[2], "/") != 1 {
            page := &Page{
                Title: line[0],
                Path:  line[1] + line[2],
                PV:    line[3],
            }
            result = append(result, page)
        }
    }

    sort.Slice(result, func(i, j int) bool {
        a, _ := strconv.Atoi(result[i].PV)
        b, _ := strconv.Atoi(result[j].PV)
        return a > b
    })

    return result, nil
}

次にslackへ通知を飛ばすロジックです。
やっていることはslackのwebhookの仕様に合わせたstructを用意して、さきほど取得したpv数のランキングをそのstructに押し込んでそのままjsonに変換してpostしているだけです。
jsonライクな形でstructを定義して、そのままシームレスに変換できているところを見てもらえばGoのjsonの扱いやすさの一端を垣間見てもらえるのかなと思います。

// repository/analytics.go

type Post struct {
    Fallback string `json:"fallback"`
    Pretext  string `json:"pretext"`
    Title    string `json:"title"`
    Text     string `json:"text"`
    Color    string `json:"color"`
    Footer   string `json:"footer"`
}

type payload struct {
    Attachments []*Post `json:"attachments"`
}

func (a *slackImpl) Post(path string, msg []*Post) error {
    params, err := json.Marshal(payload{
        Attachments: msg,
    })
    if err != nil {
        return err
    }
    payload := url.Values{"payload": {string(params)}}
    fmt.Print(payload)
    res, err := http.PostForm(path, payload)
    if err != nil {
        return err
    }
    defer res.Body.Close()

    return nil
}
// usecase/notify.go

func (n *notifyImpl) Run() error {
    post := []*repository.Post{}
    post = append(post, &repository.Post{
        Fallback: os.Getenv("SUCCESS_FALLBACK"),
        Pretext:  os.Getenv("SUCCESS_FALLBACK"),
    })

    adp := repository.NewAnalyticsRepository()
    data, err := adp.GetSessions("today", "today")
    if err != nil {
        return err
    }
    line := n.createRankingData("今日のpv数ランキング", "#4286f4", data)
    post = append(post, line)

  (略)

    slack := repository.NewSlackRepository()
    err := slack.Post(os.Getenv("SUCCESS_WEBHOOK_URL"), post)
    if err != nil {
        return err
    }

    return nil
}

func (n *notifyImpl) createRankingData(title string, color string, data []*repository.Page) *repository.Post {
    text := []string{}
    for i, line := range data {
        if i >= 5 {
            break
        }
        text = append(text, fmt.Sprintf("[%d] <https://%s|%s>: %spv", i+1, line.Path, line.Title, line.PV))
    }
    post := &repository.Post{
        Title: title,
        Text:  strings.Join(text, "\n"),
        Color: color,
    }

    return post
}

最後にデプロイですがServerless✕Goの場合はMakefileを利用してビルドからデプロイまでを自動化します。
詳しくはTech Do Book #2で触れているのでそちらを参考にしていただければと思います。

.PHONY: build clean deploy

stage = dev

build:
  env GOOS=linux CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/handler handler/main.go

clean:
  rm -rf ./bin

deploy: clean build
  sls deploy --verbose --stage ${stage}
$ make deploy

というわけで結果できたものがこちらになります。

f:id:qazx7412:20191128190623p:plain
ホンダだけならケイスケかもしれないのでセーフ

このランキングは私のqiitaで収集したpv数なのですが、みなさんVue好きですね。もっとCrystalとかにも興味持ってもらっていいんですよ?

そして色々隠蔽してメディアドゥの社内slackにもデプロイ。

f:id:qazx7412:20191128190701p:plain

世の中のGopherたちはプロジェクトのレイアウトやエラーハンドリングで困っているということがよくわかりますね。

というわけで毎日アイドルにpv数を通知してもらって幸福になることができるようになりましたね!(そんな話でしたっけ…?)
さて完走した感想ですが…GoとLambdaは相性がいいということと、structの取り回しの良さなど改めて再確認できたなと思います。
サーバーレスやGoのことが気になってる皆様は、ぜひGoとLambdaでなにかちょっとしたものを作ってみるのもよいのではないでしょうか。

弊社では本業がアイドルプロデューサーなエンジニアも大歓迎ですのでもし興味のあるエンジニアがいらっしゃいましたらぜひこちらからお願いいたします。

www.wantedly.com