Cloud BuildでAPIドキュメントの生成を自動化

f:id:inari111:20181215134720p:plain

https://cloud.google.com/cloud-build より引用

ピックアップ Advent Calendar 2018 16日目です。
今日はサーバーサイドエンジニアのinari111がお送りします。
Goを毎日書いていますが、Goの話は出てきません… adventar.org

弊社ではProtocol BuffersというIDL(Interface Definition Language, インタフェース定義言語)を採用しています。
以前は、protocコマンドでProtoファイルからAPIのドキュメントを生成 -> GCS(Google Cloud Storage)にアップロードする作業を手動で行っていました。
手動でドキュメント生成をするのは辛く、そして忘れてしまうこともあるので、この記事ではCloud Buildを使って自動化するまでを解説したいと思います。

Cloud Buildとは

ビルド、テスト、デプロイなどを実行してくれる従量課金制なビルドサービスです。 1日あたり120分まで無料枠があります。
各ビルドステップはDockerコンテナで実行され、Dockerイメージは公式ビルダーcloud-builders-community もあるので必要に応じて使うことができます。

ファイル構成

Protoファイルはアプリケーションのソースコードとは別のリポジトリで管理していて、ファイル構成は以下のようになっています。
今回のゴールは、 proto/admin_api/*.proto proto/core/*.proto proto/user_api/*.proto のどれかに変更があったらProtoファイルからAPIドキュメントを生成し、GCSにアップロードをすることです。

.
├── Makefile
├── README.md
├── _doc
│ └── rpc (生成したドキュメントを置く)
├── _docker
│ └── Dockerfile
├── cloudbuild.yaml (ビルドステップを定義)
├── proto
│   ├── README.md
│   ├── admin_api (Admin API用Protoファイル)
│   │   └── aaa.proto
│   ├── core (Admin API, User API両方でimportするProtoファイル)
│   │   └── bbb.proto
│   └── user_api (User API用Protoファイル)
│       └── ccc.proto
└── rtdb
 └── hoge_db_schema.yaml (Realtime DatabaseのSchema)

トリガーを作成する

GitHubを選択 f:id:inari111:20181215121033p:plain

その後、対象のリポジトリの選択をします。

f:id:inari111:20181215121132p:plain

トリガーの設定を書いていきます。

f:id:inari111:20181215121331p:plain

GitHubにpushしたときに proto/admin_api proto/core proto/user_api 下のどれかのProtoファイルに変更があれば ビルドが実行されるようにしました。
proto/**/*.proto と書いても動かなかったので、下記のように記載しています。

cloudbuild.yamlを作成

cloudbuild.yamlを用意します。CircleCIのconfig.yamlみたいなものです。
各stepをこのように定義しました。

steps:
# Dockerビルド
- name: 'gcr.io/cloud-builders/docker'
  dir: "_docker"
  args: ['build', '-t', 'gcr.io/$PROJECT_ID/{image名}', '.']

# Protoファイルからドキュメント生成
- name: 'gcr.io/$PROJECT_ID/{image名}'
  args: ['make', 'gen-proto-doc']

# GCSにアップロードする
- name: 'gcr.io/cloud-builders/gsutil'
  dir: "_doc/rpc"
  args: ['-m', 'cp', 'index.html', '${_RPC_DOC_PATH}/${BRANCH_NAME}_${SHORT_SHA}.html']

# Container Registryにpush
images: ['gcr.io/$PROJECT_ID/{image名}']

各stepを軽く解説します。
1つ目は、Dockerをビルドします。 dir で指定したディレクトリにあるDockerfileを対象とし、 gcr.io/$PROJECT_ID/{image名} というイメージを作成します。

2つ目は、前stepでビルドしたイメージを使い、 make gen-proto-doc を実行します gen-proto-doc

protoc -Iproto --doc_out=_doc/rpc --doc_opt=html,index.html proto/**/*.proto

を実行するようにMakefileに定義していて、Protoファイルからドキュメントを生成することができます。
結果として _doc/rpc/index.htmlが作成されます。

3つ目は gsutilコマンドを使ってGCSにファイルをアップロードします。 BRANCH_NAMESHORT_SHA はcloudbuild.yaml上で使うことができる変数です。デフォルトで使えるものは他にもあるのでこちらを参照してください。https://cloud.google.com/cloud-build/docs/configuring-builds/substitute-variable-values
独自の変数をトリガーの設定ページから登録することもできます。今回はGCSのパスを_RPC_DOC_PATH として登録していました。

最後の images を書くことで Container Registryにpushできます。不要だったら記載する必要はありません。

ローカルで検証

cloud-build-localをインストール

gcloud components install cloud-build-local

cloud-build-localをインストールしたら、ローカルで実行することができます。
--configでビルドリクエストファイルを指定
--dryrun=falseでビルドを実行
--pushはビルド中に作成されたイメージをContainer Registry にpushするかどうかです
変数に _RPC_DOC_PATH を設定しているので --substitutions=_RPC_DOC_PATH=gs://{バケット名}/{パス} を指定する必要があります。
ローカルから実行する場合に限り、 BRANCH_NAMESHORT_SHA の指定も必要です。 ローカルで実行する場合はこんな感じになります。

cloud-build-local --config=cloudbuild.yaml --dryrun=false --push=false --substitutions=_RPC_DOC_PATH=gs://{バケット名}/{パス},BRANCH_NAME=hoge,SHORT_SHA=aaaaa .

↑を実行するとGCS上にアップロードできました。 f:id:inari111:20181215141458p:plain

開発時の検証やテストはcloud-build-localでだいたい事足りました。

ローカルからビルドを送信

実際にローカルからビルドを送信してリモートでビルドを実行した場合は、以下のコマンドを実行します。

gcloud builds submit --config=cloudbuild.yaml --substitutions=_RPC_DOC_PATH=gs://{バケット名}/{パス},BRANCH_NAME=hoge,SHORT_SHA=aaaaa

ブラウザからビルド実行

ブラウザからも実行可能です。

f:id:inari111:20181215142358p:plain

GitHubへのpushをトリガーにビルド実行

これは書くまでもないですね。
ビルドを実行したくなかったら、[skip ci] または [ci skip] をコミットメッセージに書くとビルドがスキップされます。

最後に

  • Cloud Buildを使い、APIドキュメントの生成を自動化することができました
  • 特定のパスのファイルに変更があった場合のみ処理を実行することができるのは便利です
  • 今回は触れませんでしたが、ドキュメントのURLをSlackに通知するのもやっていく予定です
  • 他の会社でもProtocol Buffers採用してほしい

HHKBのキーキャップをピンクに交換した

インスタの#HHKBタグをフォローしてチェックしていると、海外でキーキャップを交換している方が多く、前々から興味がありました。

Massdrop で見つけて購入を検討していたのですがいつの間にか終わってしまい、 結局AliExpressで購入しました。値段は$32.90 + 送料
海外から配送されるので到着まで10日ほど。

このようにキーキャップとキーキャップを引き抜く工具がついてきました。
(ついていないと思って工具も買っちゃっていた...)

f:id:inari111:20181008221430j:plain

まずは、キーキャップを外します。
半年近く掃除していなかったので、ついでに掃除もしました。

f:id:inari111:20181008221450j:plain

最後にキーキャップを取り付けて完了です。

f:id:inari111:20181008221505j:plain

思ったよりも派手な感じもしますが1週間も使えば慣れました。
ErgoDoxも気になり始めたしキーボードは沼だなー

favclip/testeratorを使ってGAE/Goのテストを高速化する

testeratorはGAE/Goのテストを高速化するライブラリです。
このライブラリを使ってテストの高速化ができたので紹介したいと思います。

contextを必要とするテストでは aetest.NewContext()aetest.NewInstance() を使うかと思いますが、これを使うと goapp test実行時にローカルサーバーが関数ごとに起動し、遅くなってしまいます。

func TestHoge(t *testing.T) {
    ctx, done, err := aetest.NewContext() // ローカルサーバー起動
    if err != nil {
        t.Fatal(err)
    }
    defer done()

    // 略
}

func TestFuga(t *testing.T) {
    inst, err := aetest.NewInstance(&aetest.Options{StronglyConsistentDatastore: true}) // ローカルサーバー起動
    if err != nil {
        return nil, nil, err
    }
    req, err := inst.NewRequest("GET", "/", nil)
    if err != nil {
        inst.Close()
        return nil, nil, err
    }
    ctx := appengine.NewContext(req)
    defer inst.Close()

    // 略
}

テストの数が多くなってくると、結構ストレスが溜まります。。。

ここで使うのが testeratorです github.com

testeratorはテスト間でインスタンスを使い回すことができるため、テストが高速化できるという仕組みです。
では、実際に使っていきます。

import (
    "fmt"
    "os"
    "testing"

    "github.com/favclip/testerator"
    _ "github.com/favclip/testerator/datastore"
    _ "github.com/favclip/testerator/memcache"
    _ "github.com/favclip/testerator/search"
)

func TestMain(m *testing.M) {
    if _, _, err := testerator.SpinUp(); err != nil {
        fmt.Printf("testerator.SpinUp error: %v", err)
        os.Exit(1)
    }

    status := m.Run()

    if err := testerator.SpinDown(); err != nil {
        fmt.Printf("testerator.SpinDown error: %v", err)
        os.Exit(1)
    }

    os.Exit(status)
}

func TestHoge(t *testing.T) {
    instance, ctx, err:= testerator.SpinUp()
    if err != nil {
        t.Fatal(err)
    }
    defer testerator.SpinDown()

    // 略
}

func TestFuga(t *testing.T) {
    instance, ctx, err := testerator.SpinUp()
    if err != nil {
        t.Fatal(err)
    }
    defer testerator.SpinDown()

    // 略
}

これだけです。 TestMainm.Run で各テストを実行していきます。
僕はTestMainを書くのを忘れていて、あれ...全然早くならないぞ...と少し悩んだので忘れないようにしましょう。

If you want to clean up Datastore or Search API or Memcache, You should import above packages.

とGoDocに書いてあるように、必要に応じてimportしておきましょう。

contextやinstanceを必要とする全てのテストを書き換えた結果、改善前の時間より 約30% 短縮することができました

まとめ

  • aetest.NewContext()aetest.NewInstance() はテスト関数ごとにローカルサーバーが起動するので遅い
  • testeratorでインスタンスを使い回すことでテストの時間を30%短縮できた

FirebaseUIでTwitterログイン機能を実装する

FirebaseUIはFirebase Authentication SDK上に構築されるライブラリで、アプリで使用するログイン機能とUIを提供してくれるものです。
メールアドレス、FacebookTwitterGitHub、電話番号といった複数のプロバイダに対応しています。
今回はTwitterでログインできるようにします。

Twitter Appsの作成

https://apps.twitter.com/app/new から新しいAppsを作成します。
Callback URLは後で入力します。

f:id:inari111:20180722221015p:plain

Firebase Authenticationの設定を変更する

Authentication -> ログイン方法 からログインプロバイダの設定を変更します。
今回はTwitterを有効にしました。
APIキーとAPIシークレットは ↑で作成したAppsのものを使いましょう。

f:id:inari111:20180722221353p:plain

コールバックURLをTwitter Appに追加

↑でTwitterを有効にするときに表示されたコールバックURLをTwitter Appに追加します。

コード

Firebaseのドキュメントのコードほぼそのままで動きます。 (僕はconfig間違えてて無駄にハマった。。。) configの値は各自の環境に合わせてください。

<script src="https://www.gstatic.com/firebasejs/5.0/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/5.0/firebase-auth.js"></script>

<script src='https://cdn.firebase.com/js/client/2.2.1/firebase.js'></script>
<script src="https://cdn.firebase.com/libs/firebaseui/2.5.1/firebaseui.js"></script>
<link type="text/css" rel="stylesheet" href="https://cdn.firebase.com/libs/firebaseui/2.5.1/firebaseui.css" />

<script>
    var config = {
        apiKey: "<API_KEY>",
        authDomain: "<PROJECT_ID>.firebaseapp.com",
        databaseURL: "https://<DATABASE_NAME>.firebaseio.com",
        storageBucket: "<BUCKET>.appspot.com",
        messagingSenderId: "<SENDER_ID>",
    };
    firebase.initializeApp(config);

    var ui = new firebaseui.auth.AuthUI(firebase.auth());

    var uiConfig = {
        callbacks: {
            signInSuccessWithAuthResult: function(authResult, redirectUrl) {
                // User successfully signed in.
                // Return type determines whether we continue the redirect automatically
                // or whether we leave that to developer to handle.
                return true;
            },
            uiShown: function() {
                // The widget is rendered.
                // Hide the loader.
                document.getElementById('loader').style.display = 'none';
            }
        },
        // Will use popup for IDP Providers sign-in flow instead of the default, redirect.
        signInFlow: 'popup',
        signInSuccessUrl: 'http://example.com',// Sign in後の遷移先
        signInOptions: [
            // Leave the lines as is for the providers you want to offer your users.
            firebase.auth.GoogleAuthProvider.PROVIDER_ID,
            firebase.auth.FacebookAuthProvider.PROVIDER_ID,
            firebase.auth.TwitterAuthProvider.PROVIDER_ID,
            firebase.auth.GithubAuthProvider.PROVIDER_ID,
            firebase.auth.EmailAuthProvider.PROVIDER_ID,
            firebase.auth.PhoneAuthProvider.PROVIDER_ID
        ],
        // Terms of service url.
        tosUrl: ''
    };

    ui.start('#firebaseui-auth-container', uiConfig);

</script>
<h1>Welcome to My Awesome App</h1>
<div id="firebaseui-auth-container"></div>
<div id="loader">Loading...</div>

こんな感じで表示されます。 f:id:inari111:20180722220426p:plain

Sign in with Twitter をクリックした後 Authorize app すればログイン完了です。
Firebase Authenticationのコンソールを見るとユーザーが作成されていることがわかります。

f:id:inari111:20180722222559p:plain

まとめ

FirebaseUIを使うと簡単にTwitterログインを実装することができました。
次はFirebase SDKを使ってログイン機能の実装をしていきたいと思います。

BigQueryの集計結果をテーブルに保存した後GCSにエクスポートしてみた

こういう細かいネタもブログに残していこうと思います。
ってブログ書く度に決意しているんだけど続かないですね...

BigQueryの集計結果をテーブルに保存

使うpackageは cloud.google.com/go/bigquery です。このpackageはまだbetaらしいです。
google.golang.org/api/bigquery/v2 でもできるはずですが、cloud.google.com/go/bigqueryのほうが簡潔に書けそうな感じがしました。

コードはこんな感じ
AppEngine上で動かすコードになっていますが、一部変更すれば、AppEngine以外でも動くはずです。

package hoge

import (
    "context"

    "cloud.google.com/go/bigquery"
    "google.golang.org/appengine"
)

// BigQueryのSchema例
type schema struct {
    UserID   string
    UserName string
}

const datasetID = "test"
const tableID = "test"
const query = ""

// 関数名適当で申し訳ない
func bq(ctx context.Context) error {
    client, err := bigquery.NewClient(ctx, appengine.AppID(ctx))
    if err != nil {
        return err
    }

    // 集計結果を保存するDatasetを作成
    d := client.Dataset(datasetID)
    d.Create(ctx, &bigquery.DatasetMetadata{
        Location: "US", // お好みで
    })

    // 集計結果を保存するTable作成
    s, err := bigquery.InferSchema(schema{})
    if err != nil {
        return err
    }
    table := client.Dataset(datasetID).Table(tableID)
    if err := table.Create(ctx, &bigquery.TableMetadata{Schema: s}); err != nil {
        return err
    }

    // Queryの準備
    q := client.Query(query)
    q.WriteDisposition = bigquery.WriteTruncate // お好みで
    q.Dst = &bigquery.Table{
        ProjectID: appengine.AppID(ctx),
        DatasetID: datasetID,
        TableID:   tableID,
    }
    // 最大レスポンスサイズを超えそうだったらtrueにする
    // see: https://cloud.google.com/bigquery/querying-data#large-results
    q.AllowLargeResults = true

    job, err := q.Run(ctx)
    if err != nil {
        return err
    }
    jobStatus, err := job.Wait(ctx)
    if err != nil {
        return err
    }
    if err := jobStatus.Err(); err != nil {
        return err
    }
    return nil
}

BigQueryのテーブル指定してをGCSにexport

CSVファイルにしてexportしてみます

package hoge

import (
    "cloud.google.com/go/bigquery"
    "google.golang.org/appengine"
)

const datasetID = "test"
const tableID = "test"
// export先を指定
const gcsURI = "" // TODO

func export(ctx context.Context) error {
    client, err := bigquery.NewClient(ctx, appengine.AppID(ctx))
    if err != nil {
        return err
    }

    gcsRef := bigquery.NewGCSReference(gcsURI)
    gcsRef.SourceFormat = bigquery.CSV // defaultはCSV
    gcsRef.FieldDelimiter = ","        //defaultは ","
    gcsRef.AllowQuotedNewlines = true

    extractor := client.Dataset(datasetID).Table(tableID).ExtractorTo(gcsRef)
    extractor.DisableHeader = false // お好みで
    job, err := extractor.Run(ctx)
    if err != nil {
        return err
    }
    status, err := job.Wait(ctx)
    if err != nil {
        return err
    }
    if status.Err(); err != nil {
        return err
    }
    return nil
}

まとめ

  • cloud.google.com/go/bigquery を使って集計結果の保存とGCSへのexportをやってみました
  • bigquery packageを初めて使ったので調査のコストは少しありました
  • UTF-8でexportされるのでExcelで開くと文字化けするかも...

Goのstructを比較してdiffを見るなら godebug/pretty が便利

Goのテストでstructを比較するときに reflect.DeepEqual で比較することがあります。

テストを書き、テストを実行するとFAILだったとき...つらい...
structが大きいとさらにつらい。どのフィールドの値が違うのかわかると早くテストを直せて(または実装を直せて)嬉しいですよね。

そんなときgodebug/prettyを使うとdiffを見れて便利です。

github.com

こんなUserのstructがあったとします

type User struct {
    ID string
    Name string
    Email string
}

テストでstructを reflect.DeepEqual で比較し一致するかをチェックします
一致しなかった場合、pretty.Compare でdiffを表示してみます

func TestCompare(t *testing.T) {
    tests := []struct{
        user User
        want User
    } {
        {
            user: User{
                ID: "id",
                Name: "inari",
                Email: "inari111@example.com",
            },
            want: User{
                ID: "id",
                Name: "inari111",
                Email: "inari111@example.com",
            },
        },
        {
            user: User{
                ID: "id",
                Name: "inari111",
                Email: "inar222@example.com",
            },
            want: User{
                ID: "id",
                Name: "inari111",
                Email: "inari111@example.com",
            },
        },
    }

    for _, test := range tests {
        if !reflect.DeepEqual(test.user, test.want) {
            t.Errorf("error: %s", pretty.Compare(test.user, test.want))
        }
    }
}

テスト実行結果

$ go test
--- FAIL: TestCompare (0.00s)
    user_test.go:49: error:  {
          ID: "id",
        - Name: "inari",
        + Name: "inari111",
          Email: "inari111@example.com",
         }
    user_test.go:49: error:  {
          ID: "id",
          Name: "inari111",
        - Email: "inar222@example.com",
        + Email: "inari111@example.com",
         }
FAIL
exit status 1
FAIL    github.com/inari111/godebug-pretty  0.011s

小さいstructなら必要ありませんが、大きいstructの場合は助かりますね。

Goでsliceを結合するときのパフォーマンスについて

sliceを結合するときに、どういう書き方をするのがパフォーマンスがいいのか気になって検証してみた。 検証したのは2パターン。

パターン1: slice同士を結合

func appendSlice(n int) []string {
    sliceA := make([]string, n)
    sliceB := make([]string, n)

    for i := 1; i <= n; i++ {
        sliceA = append(sliceA, "a")
        sliceB = append(sliceB, "b")
    }
    sliceA = append(sliceA, sliceB...)

    return sliceA
}

パターン2: forで回しながらappend

func appendSlice2(n int) []string {
    sliceA := make([]string, n)
    sliceB := make([]string, n)

    for _, s := range sliceB {
        sliceA = append(sliceA, s)
    }

    return sliceA
}

パフォーマンスを計測するためにベンチマークを書く。

package main

import "testing"

func BenchmarkAppendSlice(b *testing.B) {
    b.ResetTimer()
    appendSlice(b.N)
}

func BenchmarkAppendSlice2(b *testing.B) {
    b.ResetTimer()
    appendSlice2(b.N)
}

パターン1のベンチマーク

$ go test -count 10 -test.bench BenchmarkAppendSlice

BenchmarkAppendSlice-8       2000000           631 ns/op
BenchmarkAppendSlice-8       2000000           596 ns/op
BenchmarkAppendSlice-8       2000000           631 ns/op
BenchmarkAppendSlice-8       2000000           595 ns/op
BenchmarkAppendSlice-8       2000000           602 ns/op
BenchmarkAppendSlice-8       2000000           600 ns/op
BenchmarkAppendSlice-8       2000000           596 ns/op
BenchmarkAppendSlice-8       2000000           598 ns/op
BenchmarkAppendSlice-8       2000000           597 ns/op
BenchmarkAppendSlice-8       2000000           596 ns/op
PASS
ok      github.com/inari111/test    18.915s

パターン2のベンチマーク

$ go test -count 10 -test.bench BenchmarkAppendSlice2
BenchmarkAppendSlice2-8      5000000           253 ns/op
BenchmarkAppendSlice2-8     10000000           243 ns/op
BenchmarkAppendSlice2-8     10000000           206 ns/op
BenchmarkAppendSlice2-8     10000000           204 ns/op
BenchmarkAppendSlice2-8     10000000           198 ns/op
BenchmarkAppendSlice2-8     10000000           205 ns/op
BenchmarkAppendSlice2-8     10000000           200 ns/op
BenchmarkAppendSlice2-8     10000000           201 ns/op
BenchmarkAppendSlice2-8     10000000           201 ns/op
BenchmarkAppendSlice2-8     10000000           202 ns/op
PASS
ok      github.com/inari111/test    22.293s

まとめ

パターン1のslice同士の結合のほうがパフォーマンスよさそう。
もし間違ってたら教えていただけると嬉しい。