oinume journal

Scratchpad of what I learned

GCPのCloud PubSubをGoで動かしてみただけのメモ

これはGCPのCloud PubSubのチュートリアルをやってみただけの自分用のメモ。この記事で紹介されているサンプルコードはGitHubに置いてある。

Cloud PubSubとは

GCPで提供されているメッセージキュー。メッセージの送信をして(publish)、複数のシステムがそのメッセージを受信(subscribe)することができる。

登場する概念

  • Message: PubSubを通じて送受信したいメッセージ
  • Publisher: Messageを生成しTopicに送るもの
  • Topic: Messageの送信先
  • Subscription: Topicに対して紐付けられるMessageを受信するもの。1つのTopicに対して複数のSubscriptionを設定することができる
  • Subscriber: Subscriptionと関連付けられた、Messageを受信するシステム

詳細はドキュメントに書いてある。

イメージ

使ってみる

APIを有効にする

gcloud services listで検索して

$ gcloud config set project oinume-pubsub-sample
$ gcloud services list --available | grep pubsub
pubsub.googleapis.com Cloud Pub/Sub API

enableで有効にする。

$ gcloud services enable pubsub.googleapis.com
Operation "operations/acf.dd53c399-e6be-48c3-a087-ff01c982d0d8" finished successfully.

Topicの作成

first-topic という名前のTopicを作る。

$ gcloud pubsub topics create first-topic
Created topic [projects/oinume-pubsub-sample/topics/first-topic].

Subscriptionの作成

first-topic へのSubscriptionを作る。SubscriptionはPull型とPush型の2つの種類があるが、今回はPush型を使っている。この2つについて詳しく知りたい場合はドキュメントを参照。--push-endpoint の詳細については次で説明する。

$ gcloud pubsub subscriptions create first-topic-subscription \
—topic first-topic \
—push-endpoint="https://oinume-pubsub-sample.appspot.com/_ah/push-handlers/first-topic“

Google App Engineでpush型のsubscriberを作る

先ほど --push-endpoint で指定したendpointをGAEで実装してみる。これはPubSubのmessageがpublishされたときにGCPから呼ばれるもの。GitHubにコードを上げてあるが、 "/_ah/push-handlers/first-topic のURLのHandlerを定義して、標準出力に来たmessageを出力するだけのコードである。

main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("/", indexHandler)
    http.HandleFunc("/_ah/push-handlers/first-topic", pushHandler)

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
        log.Printf("Defaulting to port %s\n", port)
    }

    log.Printf("Listening on port %s", port)
    log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
        http.NotFound(w, r)
        return
    }
    fmt.Fprint(w, "Hello, World!!")
}

func pushHandler(w http.ResponseWriter, r *http.Request) {
    type pushRequest struct {
        Message struct {
            Attributes map[string]string
            Data       []byte
            ID         string `json:"message_id"`
        }
        Subscription string
    }

    message := &pushRequest{}
    if err := json.NewDecoder(r.Body).Decode(message); err != nil {
        log.Printf("Could not decode body: %v\n", err)
        http.Error(w, fmt.Sprintf("Could not decode body: %v", err), http.StatusBadRequest)
        return
    }

    log.Printf("Data = %v\n", string(message.Message.Data))
    fmt.Fprint(w, "ok")
}

messageをpublishしてみる

以下のような感じでpublisherを実行すると first-topic にmessageがpublishされる。 oinume-pubsub-sample は自身のGCPプロジェクト名に置き換えること。

$ export GOOGLE_CLOUD_PROJECT=oinume-pubsub-sample
$ export GO111MODULE=on
$ go run cmd/publisher/publisher.go first-topic "Hello world"

実際にmessageのsubscribeが成功したかどうかはGCPのStackdriver Loggingで見ることができる。

疑問

  • push-handlerがHTTPで200以外を返した場合、どのぐらいのタイミングで再送される?
  • 再送する回数の上限は指定できる?

プログラマのためのGoogle Cloud Platform入門 サービスの全体像からクラウドネイティブアプリケーション構築まで

プログラマのためのGoogle Cloud Platform入門 サービスの全体像からクラウドネイティブアプリケーション構築まで

2019年02月の振り返り

アウトプット

特になし

英語

1月とほぼ変わらずという状態。ふと、2年前ぐらいに受けたTOEICの結果を見ていたら、Readingにおいて自分は単語力が弱いと書かれていたので、単語をちゃんと勉強するのが一番効果的っぽい。なお、今まで会社の補助で使えていたDMM英会話が使えなくなり途方にくれている。

アルゴリズム

やっとBTreeを実装した。写経していたはずが、些細なバグに悩まされ時間がかかってしまったけどやっと実装できてよかった。この記事をおおいに参考にさせてもらった。多謝。

来月はグラフをやる。

Fitbit Charge3

時計をPebble TimeからFitbit Charge3に変えた。のだけど、Pebbleに比べてFitbitが微妙過ぎてつらい。

  • 時計盤が常に表示されているわけではないので、時刻を見たい時は常に腕を回す動作をしないといけない。場合によってはこれでも表示されないので、わざわざ画面をタップしないといけない。
    • これはいろいろ調べたけど設定変更で直せるものではないっぽいぞ... Pebbleは常に表示されていたので良かった。
  • 通知のバイブレーションが微妙。通知が連続できたのか、それとも電話が鳴っていているのかバイブのパターンではわからない。
  • 自転車に乗っている時でも歩いたことになっているようでステップ数がカウントされているっぽい
    • これはまだちゃんと確認してない
  • 標準のFitbitアプリからiOSのヘルスケアにデータを同期してくれない。

というわけでPebbleの完成度の高さをあらためて知るのであった。Pebble自体は会社がFitbitに買収されてディスコンなんだけど、Pebble2あたりを買い直そうか真剣に悩んでいる。

楽天銀行のデビットカードはいいぞ

最近メインバンクを三菱UFJから楽天銀行に移している。現在進行系なのは、様々な引き落としの手続きに時間がかかっているためだけど、近日中には終わると思う。

デビットカードってなに?メリットは?

使うときはクレジットカードと同じように使える。例えばVISAブランドのデビットカードであればVISAの加盟店であればどこでも使える。クレジットカードみたいに請求が遅れてくることがないため、「気づいたら今月30万もクレカで使っていた」というようなことが起きにくい。また、銀行が発行するデビットカードはたいていキャッシュカードとの一体型になっているので、クレカとキャッシュカードの2枚持ちに終止符を打つことができる。デビットカードではなくクレジットカードを使いたい場合、楽天銀行のキャッシュカードと一体型のものも存在するのでこれを使うのもいいかもしれない。

また、ほとんどの銀行においてクレジットカードのような審査がないという点も大きなメリットの一つだと思う。収入が少ない学生や定職についてない人などは審査がないことは大きなメリットのはず。

なぜ楽天銀行なの?

  • 楽天ポイントがつく。そして獲得したポイントをデビットカードでの支払いに使うことができる
  • もともと楽天カードを使っていた
  • ATM引き出しの手数料や振込手数料が無料になる条件が三菱UFJに比べると緩い
    • 楽天銀行の場合: 給与振り込み口座にするとそれだけで他行への振込手数料が3回無料になる
    • 預ける資産が300万円以上でATM利用手数料7回無料、他行振込手数料が3回無料になる。
    • 三菱UFJの場合は、預ける資産が500万円以上ではないとATM手数料や振込手数料が無料にならない
  • (これは楽天銀行だけではなくネット銀行のメリットだけど)印鑑とか紙の通帳から開放されたかった

実際どうなのか?

良いところ

  • クレジットカードだと明細に残るまでに数日から数週間のタイムラグがあるが、デビットカードだとそれがないのがやっぱりいい
  • あとは楽天ポイントをデビットカード利用時に使えること。

不便なところ

  • デビットカードで決済すると、リアルタイムに自分の銀行口座から引き落としされるんだけど、明細には「どの店で利用したか」という情報が残らないっぽい。なので、マネーフォワードと連携していても「この5000円何に使ったんだっけ?」というのがわかりづらい。
  • マネーフォーワードと連携する場合、クレカであれば単純に支出が記録されるだけだが、銀行口座の連携の場合は他の口座への振替や現金引き出しも連携されてデータが残ってしまうので、マネーフォーワードで都度計算対象から外すのが面倒

というのが感じているところ。まぁ我慢できる範囲かなと思ってこの2つに関しては諦めている。

さいごに

メガバンクは早く淘汰されてほしいので、ネット銀行に移行しよう(煽り)

学校では教えてくれない大切なこと3お金のこと

学校では教えてくれない大切なこと3お金のこと

クライアントを作って理解するOAuth2(実装編)

はじめに

クライアントを作って理解するOAuth2(準備編) - oinume journalの続編。前の記事ではGoogle APIsのプロジェクトを作成してOAuth2 clientを登録した。この記事では発行されたClient idを使って実際にAccess tokenを取得する部分をGoで実装してみたいと思う。

ソースコードは GitHub - oinume/go-oauth2-client-sample: OAuth2 client sample in Go にあり、OAuthの実装はserver.goにまとまっている。main関数はcmd/oauth2-client-sample/main.goにあるが、これはサーバーの起動処理があるだけの薄いものである。サーバーの起動方法などについてはGitHubのREADMEを見てほしい。

前提

  • OAuth2のGrant typeはAuthorization code grantを使用する
  • Webブラウザを使用する

Webブラウザを使うことに関しては特に疑問はないと思うが、Authorization code grantって何?というのを説明しておく。

これはEnd userから認可を得て、Access tokenを取得するための手法の一つ。Authorization codeというトークンをAuthorization server(今回だとGoogleの認可サーバー)から取得して、そのAuthorization codeとAccess tokenを交換する。なお、Authorization code grantを使用すると、Access token以外にもRefresh tokenも取得ができる。このgrant typeはConfidential clientから実行されることを想定している。Confidential clientについては次に説明する。

Public clientとConfidential client

OAuth2.0の仕様にその定義が書いてあるが以下のようになっている。(RFCの日本語訳より抜粋)

  • コンフィデンシャル (confidential): クレデンシャルの機密性を維持することができるクライアント (例えば, クライアントクレデンシャルへのアクセスが制限されたセキュアサーバー上に実装されたクライアント), または他の手段を使用したセキュアなクライアント認証ができるクライアント.
  • パブリック (public): (例えば, インストールされたネイティブアプリケーションやブラウザベースのWebアプリケーションなど, リソースオーナーのデバイス上で実行されるクライアントのように) クライアントクレデンシャルの機密性を維持することができず, かつ他の手段を使用したセキュアなクライアント認証もできないクライアント.

今回はGoで実装されたサーバーからGoogleのAuthorization serverに対して通信するため、コンフィデンシャルクライアントになる。

Access tokenを取得するまでの流れ

  1. End userがSign in with Googleボタンをクリックし、Clientのサーバーにリクエストが送信される
  2. Clientが認可リクエストを認可サーバーに送信する
  3. 認可サーバーが認可画面のHTMLをレスポンスで返す
  4. Webブラウザで認可画面を表示
  5. End userが認可を行い、認可サーバにリクエストが送信される
  6. 認可サーバが認可コードを生成し、ClientのサーバーにRedirectする
  7. Clientのサーバーがstateをチェックし、認可コードからトークンリクエストを認可サーバーに送信する
  8. 認可サーバーが認可コードをチェックしてAccess tokenを生成する
  9. 認可サーバーからClientのサーバーにリダイレクトしてAccess tokenをClientに返す
  10. ClientはAccess tokenを取得する

ソースコードベースでの説明

ソースコードの詳細な説明に入る前に全体の構成を説明する。今回作るAuth2 Clientのサーバーは以下のendpointを持っている。これらのendpointはソースコード上ではserver.goに定義されている。今回は説明のしやすさのためGoのOAuth2ライブラリであるgolang.org/x/oauth2は使用せず独自で実装している。

  • /: Sign in with Googleボタンを表示するためのHTMLを返す
  • /oauth2/authorize: Sign in with GoogleボタンがクリックされるとこのURLが呼び出される。この処理の中でAuthorization serverへリダイレクトし、Authorization requestを送る。
  • /oauth2/callback: Authorization serverからリダイレクトで呼び出される。Authorization codeを受け取るので、それをもとにToken requestを送ってAccess tokenを取得する
  • /static: Sign in with Googleボタンのイメージの静的リソースを返す

それではendpointごとに詳細に説明していくとしよう。//static は特に難しいところはないので省略する。

/oauth2/authorize

以下がこのendpointに対するメソッドで、おおまかに説明すると以下のような流れになっている。

  1. まずgenerateStateでstateを生成する
  2. createAuthorizationRequestURLでURLを生成する。このURLにはAuthorization requestに必要なquery stringなどが含まれている。
  3. stateをCookieにセットする
  4. Authorization server(Google)にリダイレクトする
func (s *server) authorize(w http.ResponseWriter, r *http.Request) {
    state, err := generateState()
    if err != nil {
        s.writeError(w, http.StatusInternalServerError, err)
        return
    }
    u, err := s.createAuthorizationRequestURL(redirectURI, scopes, state)
    if err != nil {
        s.writeError(w, http.StatusInternalServerError, err)
        return
    }
    log.Printf("authorization request url = %v\n", u)

    // Set state to cookie
    cookie := &http.Cookie{
        Name:     stateCookieName,
        Value:    state,
        Path:     "/",
        Expires:  time.Now().Add(10 * time.Minute),
        HttpOnly: true,
    }
    http.SetCookie(w, cookie)

    // Send authorization request by redirection
    http.Redirect(w, r, u.String(), http.StatusFound)
}

createAuthorizationRequestURLが重要なので、次にこのメソッドを詳細に説明する。

func (s *server) createAuthorizationRequestURL(
    redirectURI string,
    scopes []string,
    state string,
) (*url.URL, error) {
    u, err := url.Parse(authorizationEndpoint)
    if err != nil {
        return nil, err
    }

    q := u.Query()
    q.Set("response_type", "code")
    q.Set("client_id", s.clientID)
    if redirectURI != "" {
        q.Set("redirect_uri", redirectURI)
    }
    if len(scopes) > 0 {
        q.Set("scope", strings.Join(scopes, " "))
    }
    q.Set("state", state)
    q.Set("prompt", "consent")
    u.RawQuery = q.Encode()

    return u, nil
}

このメソッドは引数にredirectURI, scopes, stateを受け取る。redirectURIおよびscopesは動的に生成されるものではないのでハードコードしてもよいが、再利用性を考慮してメソッド外部から渡せるようにしている。

このメソッドでは

  1. authorizationEndpointからurl.URLを生成する
  2. 必要なパラメータをquery stringとしてセットする
  3. uにquery stringをURL encodeしてセットする
  4. 生成したAuthorization requestのURLであるuを返す

という処理を行っている。必要なパラメータについてはOAuth2.0の仕様に定義されているが、簡単に説明すると以下である。

  • response_type: Authorization code grantのためcodeを指定
  • client_id: Googleから発行されたClient id
  • redirect_uri: Googleに登録したRedirect URI
  • scope
  • state: 先に生成したランダム文字列
  • prompt: 今回は認可画面を必ず表示するためにconsentを指定しているが、なくても良い。

話をもとに戻して、createAuthorizationRequestURLで生成されたURLに対してHTTPリダイレクトを行い、GoogleのAuthorization serverにAuthorization requestを送るのがこのメソッドで行っている全てである。

認可画面の表示

Authorization requestが成功すると、Authorization serverから認可画面のHTMLがレスポンスとして返ってくるので、Webブラウザはそれを表示する。Googleの場合だと以下のような画面になる。(Googleにログインしていない場合は、認可画面の前にログイン画面が表示される)

認可画面で表示されている内容は、gmail-fetcherというClient(3rd party application)が、表示されているGoogleアカウントのメール メッセージと設定の表示(scope)を行うことを許可するかどうかを確認するというものだ。ここで許可するというボタンをクリックすると、gmail-fetcherというClientに対してアクセスする許可を与えたので、Access tokenが発行されることになる。

/oauth2/callback

End userが認可画面で許可するボタンをクリックすると、Authorization serverからこのendpointにリダイレクトでコールバックされる。メソッドのコードは以下。

func (s *server) callback(w http.ResponseWriter, r *http.Request) {
    log.Printf("callback: state=%v, code=%v", r.FormValue("state"), r.FormValue("code"))

    if e := r.FormValue("error"); e != "" {
        // Should handler error correctly as described in https://tools.ietf.org/html/rfc6749#section-4.2.2.1
        s.writeError(w, http.StatusBadRequest, fmt.Errorf("error returned in authorization: %v", e))
        return
    }
    if err := validateState(r); err != nil {
        s.writeError(w, http.StatusBadRequest, err)
        return
    }
    code := r.FormValue("code")
    if code == "" {
        s.writeError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
        return
    }

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    token, err := s.exchange(ctx, code)
    if err != nil {
        s.writeError(w, http.StatusInternalServerError, err)
        return
    }

    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, "accessToken = %v", token.AccessToken)
    // save token to database or do something
}

やっていることとしては以下になる。OAuth2.0の仕様としてはAuthorization Responseに記載されている。

  1. errorが渡って来ているかどうかをチェック
    • 今回の実装では、errorがある場合は400エラーを返すようにしているが、本来であればこれはエラーの種類に応じて適切なエラー画面を表示するのが正しい実装だと思う。エラーの種類に関してもOAuth2.0の仕様に定義されている。
  2. checkStateを呼び出して、Cookieに保存したstateとcallbackのパラメータで来たstateが一致しているかをチェック
    • なぜこのようなチェックをしているかは長くなるので別の記事で説明する予定
  3. codeが渡って来ているかをチェック
  4. exchangeを呼び出してcodeからAccess tokenを取得
  5. Access tokenをレスポンスとして返す
    • Webブラウザに取得したAccess tokenが表示される
    • ちゃんと実装するなら、Access tokenをDBに保存したりするはず

exchangeメソッドについては長くなるので次で詳細に説明する。

exchangeメソッド

これはAuthorization codeからAccess tokenを取得するメソッドである。Access Token Requestに仕様が書かれている。

  1. リクエストに必要なquery stringをセットする
  2. Authorization serverがクライアント認証を行うため、Client idとClient secretをセットする
  3. Token endpointにHTTPリクエストを送る
  4. 返ってきたHTTPレスポンスを受け取って内容をパースし、Access tokenなどを得る
  5. Webブラウザで表示するためにAccess tokenをレスポンスにセット

ということがやっていることだ。query stringにセットしているパラメータはそれぞれ以下になる。

  • grant_type: Authorization code grantなのでauthorization_codeをセット
  • code: Authorization codeをセット
  • redirect_uri: Authorization requestで使ったredirect_uriと同じものをセット
  • client_id: Client idをセット

また、仕様には以下のように書かれているので、Client credentialsとしてreq.SetBasicAuthでClient idとClient secretをセットしている。

The authorization server MUST:

o require client authentication for confidential clients or for any client that was issued client credentials (or with other authentication requirements),

o authenticate the client if client authentication is included,

func (s *server) exchange(ctx context.Context, code string) (*tokenEntity, error) {
    v := url.Values{
        "grant_type":   {"authorization_code"},
        "code":         {code},
        "redirect_uri": {redirectURI},
        "client_id":    {s.clientID},
    }
    req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(v.Encode()))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    // Client authentication: https://tools.ietf.org/html/rfc6749#section-4.1.3
    req.SetBasicAuth(url.QueryEscape(s.clientID), url.QueryEscape(s.clientSecret))

    // Send token request
    resp, err := http.DefaultClient.Do(req.WithContext(ctx))
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
    }
    if code := resp.StatusCode; code < 200 || code > 299 {
        // Should handler error correctly as described in https://tools.ietf.org/html/rfc6749#section-5.2
        log.Printf("token request failed: statusCode=%v, body=%v\n", code, string(body))
        return nil, fmt.Errorf("oauth2: token request failed: statusCode=%v", code)
    }

    // Create tokenEntity from response
    var token *tokenEntity
    contentType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
    if err != nil {
        return nil, fmt.Errorf("oauth2: failed to parse Content-Type header: %v", err)
    }
    if contentType != "application/json" {
        return nil, fmt.Errorf("oauth2: invalid Content-Type in response: %v", contentType)
    }
    token = &tokenEntity{}
    if err = json.Unmarshal(body, token); err != nil {
        return nil, err
    }
    if token.ExpiresIn != 0 {
        token.expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
    }

    return token, nil
}

なお、Access tokenには有効期限があり、あとどのぐらいで有効期限が切れるのか?がレスポンスのexpires_inというフィールドにセットされている。通常であればAccess tokenをDBに保存すると同時にこの有効期限も保存しておき、有効期限が切れたらRefresh tokenを使ってAccess tokenをリフレッシュするという処理が必要になる。

まとめ

細かいところは飛ばしつつも、Authorization code grantによるAccess tokenの発行まで、ライブラリを使わずに一通り実装してみた。ところどころにOAuth2.0のRFCへのリンクを入れたので、適宜参照してもらうと仕様の理解が深まるのではないかと思う。自分で実装していて思ったのは、「エラーハンドリングとか細かいところは面倒なポイントがあるけど、全体的にOAuth2.0のClientの実装って簡単じゃん!」ということ。OAuthって聞くとなんか大変そうなイメージがあったけど自分的には攻略できた感じ。この記事が仕事で外部サービスとの連携でクライアントとか認可サーバーの実装しなくてはいけない人たちの役にたてば嬉しいです。

OAuth徹底入門

OAuth徹底入門

2019年01月の振り返り

アウトプット

OAuth 2.0について以下の記事を書いた。少しかじった程度だけど、その知識をアウトプットした感じ。

journal.lampetty.net

英語

  • Daily Tech News Showをほぼ毎日聞いている
    • 1エピソードが5分以内なので聞きやすい。長い英語のpodcastは途中で集中が切れてしまうと離脱してしまうけど、5分なら集中できる
    • また、テック業界のニュースが多いので基本的な文脈はわかっているため内容も理解しやすい
    • これを聞いていて思ったのは、日本に流れてこないニュースが意外とあるのだということ。なんとなく得した気分になる
  • DMM英会話で、Daily Newsだけではなく文法をやり始めた。難易度が高い方から逆順にやっているのだけど、意外と忘れていることが多いので良い。
  • 6月にIELTSの試験を受ける予定

アルゴリズム

本は読んでいるけど特にアウトプットはなし。

アルゴリズムイントロダクション 第3版 総合版:世界標準MIT教科書

アルゴリズムイントロダクション 第3版 総合版:世界標準MIT教科書

睡眠時間

今年に入ってからなるべく早くベッドに入るようにしていい感じのペースができている。1月の平均だと5時間59分だった。6時間以上寝た次の日は頭がちゃんと働いているのがよい。

仕事

チームが変わってそのチームにさらにインターナショナルなメンバーが増えて、英語を使うことがさらに多くなった。

  • インド
  • 台湾
  • 中国
  • ノルウェー
  • 日本

というめちゃくちゃ多国籍なチームになっている。隣に座っている人はノルウェーの人は日本語もけっこう得意なんだけど、彼と話す時はだいたい英語。Slackも3分の1ぐらいは英語になっている気がする。意外なのは、英語を覚えても日本語自体の語彙や会話力は衰えていなさそうなので、これは良いことだ。

2月重点的に頑張ること

  • アルゴリズム
  • Spannerの勉強