20200406のGoに関する記事は10件です。

なぜサーバAPIプログラムはJavaではなくGoなのか?

背景

最近、プロジェクトのアプリケーション開発で選択する言語で迷う事が多い。
今一度なぜGoでアプリケーションを書くべきなのかをアーキテクトな視点でまとめてみる。
特に、既存あるいは新規プロジェクトで、サーバアプリケーションをjavaで開発すべきかGoで開発すべきか、という議論から始まる事が多い。
特にサーバサイドをコンテナ化していく、という際にAPI用プログラムにGoを選択する。
JavaではなくGoを選択する理由とは何なのか。

なぜ、「ナウいGo使おうぜ」では満足しないのか

もちろん、新しい技術を採用し知見を溜めたい。
今後の潮流にのっかるために、目の前のプロジェクトをお試しとしてGoを選択する、という案もある。
一方で、既に技術者や知見が残っている、安全性の高い言語で目の前のプロジェクトを安全に終わらせたい、という人間も少なからずいる。
そういった人間を説得しなければならず、そういった人たちには「新しいこと」は害悪である。
彼らは分からないことが怖い。新しい技術だからこそ、採用を見送りたく思うのである。
だから、新しい言語を選択したときに考慮しなければならない点、デメリットについて考えなければならない。

今回はマネジメント視点は除外し、サーバアプリケーションを開発する、という視点でのメリットとデメリットを考えてみた。

Goのメリット

  • コンテナの起動速度が非常に速い。耐障害性を考えた際に~分で新しいコンテナが起動するJavaと、秒で新しいコンテナが起動するGoではやはり後者にメリットを感じる。
  • javaであるgcがないこと。 Javaと比べてGCによるオーバーヘッドが小さいこと。オンラインシステムでGcにこれまでやられて来たよね。

Goのデメリット

  • オブジェクト指向ではないこと。

ここで、「オブジェクト指向でないこと=デメリット」と考える具体的な理由は何なのか?

なぜオブジェクト指向だったのか

C言語だった我々があるときjavaを手に入れた。その時にそもそもCで感じていた課題感とJavaのメリットは何だったのか。ドンピシャの本があったので読了
『オブジェクト指向でなぜつくるのか』 (平澤 章/日経BP)

以下、上記の本の要旨。

C言語からJavaに世界が流れた理由

Step1. 機械語からアセンブリ言語

  • 機械言語は人間には理解しにくい。人間で理解できる言葉で書けるようアセンブリ言語が生まれた。アセンブリ言語からコンパイルにより機械語に変換するようになった。

Step2. アセンブリ言語から高級言語

  • メモリ上などのコピー処理の手順を記載する必要があるなど、アセンブリではまだ記載が複雑だった。そのため、更に高級な言語が作られた。
  • Cobolは申請書のようなフォーマットで書くことを目指して。

Step3. 構造化プログラミング

ポイントは以下2点

  • 更に分かりやすく作成するために考案された。
  • GOTO死すべし

プロジェクトを分かりにくくしている元凶であるGOTO分を廃止。ロジックを逐次進行、条件分岐、繰り返しの3つのみで表現することを提唱した。そもそも、それまではプログラムが小さくメモリを利用しないことが必要とされていた。そのため、分かりやすさよりトリッキーなコードだった。 分かりやすさを考えた場合、GOTOは廃止したほうが良い。

保守性の高いサブルーチンの考え方

サブルーチンの独立性を高めて保守性を高める必要がある。

重要なのは、サブルーチンの独立性を高める方法は呼び出し側とサブルーチンで共有する情報を少なくしたい。ただし、共有する情報をグローバル変数とし、全体から参照されてしまうことでグローバル変数の書き換えについて書き換え元を特定できず、デバッグに時間がかかる。そのため、外部から参照されないローカル変数が発明されし、値渡しを前提とした。これで、サブルーチンが勝手に他のサブルーチンが参照している値の書き換えをしないようになった。

ここで残った問題点

  • グローバル変数問題:長期的に必要な値はプログラムを書くために必要。ただし、ローカル変数は関数消えたら消えてしまう。結局グローバル変数は残る。そのため、グローバル変数をどこから参照しているか、影響範囲を調べるためにロジックをすべて調べなければならない。

  • 貧弱な再利用:サブルーチン程度しか再利用されない。より大規模な再利用が必要。

Step4. オブジェクト指向

外部から参照される変数、手続きをクラス化してまとめた。
クラスの効用-まとめる、隠す、たくさん作る。これにより、大規模な再利用ができるようになった。

そして、Goを選択する理由とは?

このオブジェクト指向の流れを踏まえたうえで、言語選択について考えなければならない。
結果、Goのオブジェクト指向でないことがデメリットとなるのは、条件がある。

  • 外部から参照される変数、手続きをクラス化してまとめる必要があること
  • フレームワーク化してより大規模な再利用が必要となること。

この2つが必要となるとき、Javaが力を持つ。

しかし、「サーバサイドをコンテナ化していく、中で、サーバサイドのAPI用プログラムにGoを選択する」という場合はどうだろうか?

上記を考えた時、APIを設計する、という行為の中に上記オブジェクト指向で対応した問題点が内包されている。

APIのRestの考え方

APIではRESTFulという考え方がある。

詳細は割愛するが、RESTFulAPIでは機能を(ドメイン指向などありますが)最小単位とし、CRUDをHTTPのプロトコルで表現する
APIを設計する。これががそもそもオブジェクト指向におけるクラスの作成に似ている。
APIのバックエンドにデータベースを持つことにより、長期的なデータの保存が可能となる。
APIのインターフェースを設計することにより、外部からなんの情報が参照され、更新されるかが明確になる。
tokenのscopeにより制御範囲を明確にすることにより、誰に何を提供しているかが明確に判断できる。
グローバル変数変更する際、影響範囲を調べるためにロジックを全て調べなければならない、問題はAPIのインターフェースを変える際にv1/v2とバージョンアップすることにより、
古いインターフェースを守りながら、新しいインターフェースを提供することができる。

結論

つまり、APIの設計をする、ということがオブジェクト指向における隠ぺい化を担っている。
APIの設計をするということが、オブジェクト指向のクラスを分類する、という行為と似ているのだ。

その前提をもって、設計されたAPIの実現であれば、Goによる開発のメリットを受けられるのではないか。

マイクロサービスアーキテクチャ、を実現するためにAPIを実現するときに、軽量かつ高速なGoという言語がその真価を発揮する。メリットを享受し、デメリットをより上位の設計で吸収することができる。

と、考えてサーバAPIの実装はGoに限ると思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

OSSに参加にする敷居を下げるコマンドgossを作ったよ

ご挨拶

こんにちは。
職業は盛り上げマスター(MM)です。
盛り上げマスターとは、最近新しくアジャイルな開発で導入され話題になっている役割で(嘘)
チーム内の居心地の良さを最大化することが仕事です。要はワイガヤ担当。

Goを最近勉強しています。Go歴は1ヶ月です。
間違っている箇所など優しくご指摘いただけると嬉しいです。

そしてGo初心者が作った物なので、このコマンドを実行して何か問題が発生しても自己責任でお願いします。

作った物

https://github.com/kankanSunaga/goss

何を作るか考えた

最近これ話題になってますよね。
https://github.com/tokyo-metropolitan-gov/covid19
東京都のコロナ対策アプリです。

私もプルリクを出しましたが、案の定マージはされませんでしたw
でもこういった取り組みに参加できて良かったなと思っています。

参加して思ったのが、もっとOSSの文化が根付ばいいな〜と思いました。
そこでできるだけOSSの参加の敷居を下げたいと思い、
コマンド一発で、fork,clone,remote の追加をできるコマンドを作成しました
(hubコマンドでよくねって思っても口にしちゃダメ)

できること

現在いるパスにossに参加したいプロジェクトのfork,clone,remoteの設定ができます。

使い方

まずgithubのtokenを取得してください。
https://github.com/settings/tokens
取得したらこんな感じで下記のコマンドを実行してください。

$ echo 'export GITHUBTOKEN="取得したgithubのトークン"' >> ~/.bash_profile
$ source ~/.bash_profile
$ echo $GITHUBTOKEN
先ほど取得したgithubのトークンが表示されたらOK!

これでセットアップ完了です。
実行

  1. プロジェクトを追加したいフォルダまで移動してください。
  2. 参加したいOSSのgithubのページまで飛んでください。そしたら、画像の①と②をコピってください
    スクリーンショット_2020-04-06_22_41_25.png

  3. 以下を実行してください

$ goss 2でコピった① 2でコピった②

そしたらあら不思議
remoteにupstreamとリポジトリののforkができています(はず)

意識した点

  • Goっぽい書き方でできるだけ書く
  • 日本人のプログラミング初心者にわかりやすくするため、エラー文言をできるだけ日本語を使った
  • 無駄な機能は乗っけずに、必要最低限の物にした
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【LIFF入門?】LIFFでLINE Offical Account(旧LINE@)上に予約フォームを作成する

はじめに

こんな感じの作ります

canova.PNGcanova.PNGcanova.PNG

使用技術

  • LIFF (LINE Front-end Framework)
  • Go
    • Goの勉強したかったのでGoにしましたが、バックエンドは何でもいいです
  • JavaScript, jQuery
  • Heroku
  • Git

ソースコードはこちら(GitHub)

LINE公式アカウント作成

こちらの公式HPを参考に作成します。

リッチメニュー設定

リッチメニュー用の画像を作成する

チャット画面下に表示されるバナーのことをリッチメニューというようです。
まずはリッチメニューに設定する画像をCanovaを使って作成します。
Canovaは素材が豊富で無料プランでもいい感じの画像が作れるので便利です。
canova.PNG

リッチメニューを設定する

LINE Offical Account Manager上からいい感じに設定します。(適当)
PCからのみ設定することができ、スマホからは設定できないようです。
リンクは一旦適当なリンクを設定しておきます。
LIFFアプリを作成後にそのURLを設定します。

rich1.PNG
rich2.PNG

LIFFアプリ作成

チャネル作成

こちらの公式HPを参考にして、LINE Developersでチャネルを作成します。
LIFFを使うために基本的な情報を登録します。

コーディング

ディレクトリ構成

任意のディレクトリに作業用ディレクトリを以下の構成で作成します。
Go以外で試す場合は、index.htmlstatic/があれば動作すると思います。

reservation/
|- static/
   |- liff.js
   |- index.js
|- vendor/
   |- vendor.json // Heroku上でGoを動かすために必要
|- index.html
|- main.go

ソースコードは冒頭のGitHubを参照ください。

ハマりポイント

PORTの指定はport := os.Getenv("PORT")のように環境変数から取得します。
Herokuで実行する際はHeroku指定のPORT番号でListenする必要があるためです。

main.go
func main() {
    port := os.Getenv("PORT")
    fmt.Printf("Starting server at Port %s", port)
    http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static/"))))
    http.HandleFunc("/", handler)
    http.ListenAndServe(fmt.Sprintf(":%s", port), nil)
}

Herokuにデプロイ

Herokuに関する説明は省略します。
HerokuでNew Appを作成した後に以下のコマンドを実行します。
HerokuのDeploy画面に表示されているコマンドのままです。

$ heroku login
$ cd reservation/
$ git init
$ heroku git:remote -a (自分のApp名)
$ git add .
$ git commit -am "make it better"
$ git push heroku master

デプロイ後にHerokuのURLにアクセスしてフォームが表示されれば成功です。

LIFFアプリをチャネルに追加

こちらの公式HPを参考にして、LIFFアプリがLINEやブラウザ上で動作できるようにするために、LIFFアプリをチャネルに追加します。
エンドポイントURLにはHerokuのURLを設定します。

動作確認

LINE Offical Account ManagerのリッチメニューのリンクをLIFFの設定画面に表示されているLIFF URLをに変更します。
liffurl.PNG

LINE公式アカウントを自分のLINEの友だちに追加して試してみます。
冒頭のデモ画像のようになれば成功です。

おまけ

ローカルのサーバで動作確認をするにはngrokがとても便利です。
参考リンクを貼っておきます。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GoでGET/POST

Go の勉強がてら http リクエストを行うプログラムを作成したので適当に貼っておきます。
検索して引っかかってきた人の一助になれば幸いです。

httput.go
package httput

import (
    "context"
    "fmt"
    "io"
    "io/ioutil"
    "net/http"
    "net/http/cookiejar"
    "net/url"
    "strings"
    "time"

    "golang.org/x/text/encoding"
    "golang.org/x/text/transform"
)

const UserAgent = "My User Agent" // FIXME

type HTTPClient struct {
    http.Client
    CookieJar http.CookieJar
    UserAgent string
}

func NewClient(ctx context.Context, timeout time.Duration) (*HTTPClient, error) {
    cookieJar, err := cookiejar.New(nil)
    if err != nil {
        return nil, fmt.Errorf("error creating cookie jar: %v", err)
    }

    client := &HTTPClient{
        Client:    http.Client{Timeout: timeout, Jar: cookieJar},
        CookieJar: cookieJar,
        UserAgent: UserAgent,
    }

    return client, nil
}

func (client *HTTPClient) String() string {
    return fmt.Sprintf("Client=%v, Cookie=%v", client.Client, client.CookieJar)
}

func (client *HTTPClient) GetCookies(rawurl string) ([]*http.Cookie, error) {
    url, err := url.Parse(rawurl)
    if err != nil {
        return nil, err
    }

    cookies := client.CookieJar.Cookies(url)

    return cookies, nil
}

func (client *HTTPClient) GetCookie(rawurl, name string) (*http.Cookie, error) {
    cookies, err := client.GetCookies(rawurl)
    if err != nil {
        return nil, err
    }

    for i := range cookies {
        if strings.EqualFold(cookies[i].Name, name) {
            return cookies[i], nil
        }
    }

    return nil, nil
}

func (client *HTTPClient) AddCookie(rawurl, name, value, path string) error {
    url, err := url.Parse(rawurl)
    if err != nil {
        return err
    }

    cookie := http.Cookie{
        Name:   name,
        Value:  value,
        Path:   path,
        Domain: url.Host,
    }

    cookies := client.CookieJar.Cookies(url)
    cookies = append(cookies, &cookie)

    client.CookieJar.SetCookies(url, cookies)

    return nil
}

func (client *HTTPClient) GetURLResponse(ctx context.Context, url, method string, headers map[string]string) (*http.Response, error) {
    req, err := http.NewRequestWithContext(ctx, method, url, nil)
    if err != nil {
        return nil, err
    }

    for key, value := range headers {
        req.Header.Add(key, value)
    }

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    return resp, nil
}

func (client *HTTPClient) GetURL(ctx context.Context, url, method string, encoding encoding.Encoding, headers map[string]string) (string, error) {
    resp, err := client.GetURLResponse(ctx, url, method, headers)
    if err != nil {
        return "", err
    }

    defer resp.Body.Close()

    return GetBody(ctx, resp.Body, encoding)
}

func (client *HTTPClient) PostBodyURLResponse(ctx context.Context, url string, body io.Reader, headers map[string]string) (*http.Response, error) {
    req, err := http.NewRequestWithContext(ctx, "POST", url, body)
    if err != nil {
        return nil, err
    }

    for key, value := range headers {
        req.Header.Add(key, value)
    }

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    return resp, nil
}

func (client *HTTPClient) PostURLResponse(ctx context.Context, url string, values url.Values, headers map[string]string) (*http.Response, error) {
    copyHeaders := make(map[string]string)
    for k, v := range headers {
        copyHeaders[k] = v
    }
    copyHeaders["Content-Type"] = "application/x-www-form-urlencoded"

    return client.PostBodyURLResponse(ctx, url, strings.NewReader(values.Encode()), copyHeaders)
}

func (client *HTTPClient) PostURL(ctx context.Context, url string, values url.Values, encoding encoding.Encoding, headers map[string]string) (string, error) {
    resp, err := client.PostURLResponse(ctx, url, values, headers)
    if err != nil {
        return "", err
    }

    defer resp.Body.Close()

    return GetBody(ctx, resp.Body, encoding)
}

func GetBody(ctx context.Context, body io.ReadCloser, encoding encoding.Encoding) (string, error) {
    var bytes []byte
    var err error

    if encoding == nil {
        bytes, err = ioutil.ReadAll(body)
    } else {
        bytes, err = ioutil.ReadAll(transform.NewReader(body, encoding.NewDecoder()))
    }

    if err != nil {
        return "", err
    }

    content := string(bytes)

    return content, nil
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

How to print struct variables data?

package main

import "fmt"

type Employee struct {
    Id int64
    Name string
}

func main() {
    emp := Employee{Id:1200, Name: "hoge fuge"}
    fmt.Printf("%+v\n",emp) // Print with Variable Name
    fmt.Printf("%v\n",emp)  // Without Variable Name
    fmt.Printf("%d\n",emp.Id)
    fmt.Printf("%s\n",emp.Name)
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Go言語でKibana(Elastic Cloud)へのプロキシを作成する

この記事では App Engine Standard Environment(2nd gen) を用いて Kibana へのプロキシを作成する方法について紹介しています。

Elastic Cloud の一番安いプランで一つデプロイを持っているのですが、今回そこで可視化しているダッシュボードを不特定多数に公開したくなりました。
そこで困ったのが、Elastic Cloud には Anonymous ユーザ機能がない・・・。
ダッシュボードにアクセスさせるにはユーザにログインしてもらう必要があり、しかしパスワードとかは勝手に変更されないようにしなければならないので、みなさんどうやってるのか調べた結果、

  • nginx や Apache でリバースプロキシ
    • Bearer basic ヘッダーで自動でログイン
    • https://{ユーザ名}:{パスワード}@yoursubdomain.yourdomain/kibana では自動ログインできないので、ヘッダーで送る必要がある(ただ、ヘッダーの方がユーザパスワードを iframe の url に書かなくていいので安心できそう
    • パスワード変更のリクエストをピンポイントで弾く設定を入れる

として対応しているようでした。
この方針で問題ないのですが、自分の場合以下の問題が発生。

  • nginx 立てたくない
    • https なサイトに iframe でダッシュボードを表示したいので nginx を https 化することが必須
    • Let's encrypt とかで自動化したとしても、念のため定期的に証明書の確認しなきゃいけないのが嫌だった
    • nginx 立てるお金がない
    • f1-micro 等無料枠の仮想マシンに建てるにしても証明書管理したくなかった
    • Google App Engine Flexible なら Docker で http なリバプロ立てておけば Google が勝手に https な URL を作ってくれる(*.appspot.com)けど、Flexible Environment には無料枠がない:sob:

と言うわけで、App Engine Standard Environment で Go で適当なプロキシ作ることにしました。
特定のヘッダーにのみ気をつけて GET/POST プロキシさせるだけできれいに表示できた。
(けど、なんか他にいい方法ないですかね??)

main.go
package main

import (
    "context"
    "time"

    "cloud.google.com/go/profiler"
    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()

    router.AppEngine = true

    // Stackdriver Profiler
    profiler.Start(profiler.Config{})

    // login/logout は触れさせない
    // router.Any("/login", proxy.Proxy)
    // router.Any("/logout", proxy.Proxy)
    router.Any("/bundles/*uri", proxy.Proxy)
    router.Any("/built_assets/*uri", proxy.Proxy)
    router.Any("/node_modules/*uri", proxy.Proxy)
    router.Any("/translations/*uri", proxy.Proxy)
    router.Any("/ui/*uri", proxy.Proxy)
    router.Any("/api/*uri", proxy.Proxy)
    router.Any("/s/yourspace/*uri", proxy.Proxy) // FIXME 「yourspace」と言う名前のスペース以外触れさせない

    router.Run()
}
proxy.go
package proxy

import (
    "encoding/base64"
    "net/http"
    "strings"
    "time"

    "github.com/gin-gonic/gin"
)

const KibanaURL = "https://yourkibana.us-west-2.aws.found.io:9243" // FIXME

var Authorization = "Basic " + base64.StdEncoding.EncodeToString([]byte("youruser:yourpassword")) // FIXME

func replaceDomain(url string) string {
    return strings.ReplaceAll(url, "https://yourgaeproject.appspot.com", KibanaURL) // FIXME
}

func Proxy(ctx *gin.Context) {
    method := ctx.Request.Method

    httpClient, err := httput.NewClient(ctx, time.Duration(20)*time.Second)
    if err != nil {
        ctx.Status(http.StatusInternalServerError)
        return
    }

    if strings.EqualFold(method, "GET") {
        ProxyGet(ctx, httpClient)
    } else if strings.EqualFold(method, "POST") {
        ProxyPost(ctx, httpClient)
    } else {
        ctx.Status(http.StatusMethodNotAllowed)
        return
    }
}

func ProxyGet(ctx *gin.Context, httpClient *httput.HTTPClient) {
    uri := ctx.Request.URL.String()

    if strings.Contains(uri, "/api/spaces/space") { // スペース一覧の表示を禁止
        ctx.Status(http.StatusForbidden)
        return
    }

    if strings.HasPrefix(uri, "/s/") && !strings.Contains(uri, "/yourspace/") { // FIXME yourspace スペース以外のスペースの表示を禁止
        ctx.Status(http.StatusForbidden)
        return
    }

    isStatic := strings.HasSuffix(uri, ".js") || strings.HasSuffix(uri, ".css") || strings.HasSuffix(uri, ".svg")
    if isStatic {
        staticContent, err := redisut.Get(ctx, uri).Result()
        if err == nil { // Redis に入ってればそれを使う
            if strings.HasSuffix(uri, ".js") {
                ctx.Writer.Header().Add("Content-Type", "text/javascript")
            } else if strings.HasSuffix(uri, ".css") {
                ctx.Writer.Header().Add("Content-Type", "text/css")
            } else if strings.HasSuffix(uri, ".svg") {
                ctx.Writer.Header().Add("Content-Type", "image/svg+xml")
            }

            ctx.String(http.StatusOK, staticContent)

            return
        }
    }

    headers := make(map[string]string)
    headers["Authorization"] = Authorization
    if ctx.Request.Header.Get("kbn-version") != "" {
        headers["kbn-version"] = ctx.Request.Header.Get("kbn-version")
    }
    if ctx.Request.Header.Get("User-Agent") != "" {
        headers["User-Agent"] = ctx.Request.Header.Get("User-Agent")
    }
    if ctx.Request.Header.Get("Origin") != "" {
        headers["Origin"] = replaceDomain(ctx.Request.Header.Get("Origin"))
    }
    if ctx.Request.Header.Get("Referer") != "" {
        headers["Referer"] = replaceDomain(ctx.Request.Header.Get("Referer"))
    }

    resp, err := httpClient.GetURLResponse(ctx, KibanaURL+uri, "GET", headers)
    if err != nil {
        ctx.Status(http.StatusInternalServerError)
        return
    }

    if resp.Header.Get("Location") != "" {
        ctx.Redirect(resp.StatusCode, resp.Header.Get("Location"))
        return
    }
    if resp.Header.Get("kbn-license-sig") != "" {
        ctx.Writer.Header().Add("kbn-license-sig", resp.Header.Get("kbn-license-sig"))
    }
    if resp.Header.Get("kbn-name") != "" {
        ctx.Writer.Header().Add("kbn-name", resp.Header.Get("kbn-name"))
    }
    if resp.Header.Get("kbn-xpack-sig") != "" {
        ctx.Writer.Header().Add("kbn-xpack-sig", resp.Header.Get("kbn-xpack-sig"))
    }
    if resp.Header.Get("Content-Type") != "" {
        ctx.Writer.Header().Add("Content-Type", resp.Header.Get("Content-Type"))
    }
    if resp.Header.Get("x-cloud-request-id") != "" {
        ctx.Writer.Header().Add("x-cloud-request-id", resp.Header.Get("x-cloud-request-id"))
    }
    if resp.Header.Get("x-found-handling-cluster") != "" {
        ctx.Writer.Header().Add("x-found-handling-cluster", resp.Header.Get("x-found-handling-cluster"))
    }
    if resp.Header.Get("x-found-handling-instance") != "" {
        ctx.Writer.Header().Add("x-found-handling-instance", resp.Header.Get("x-found-handling-instance"))
    }
    if resp.Header.Get("x-found-handling-server") != "" {
        ctx.Writer.Header().Add("x-found-handling-server", resp.Header.Get("x-found-handling-server"))
    }

    body, err := httput.GetBody(ctx, resp.Body, nil)
    if err != nil {
        ctx.Status(http.StatusInternalServerError)
        return
    }

    if isStatic {
        redisut.SetNX(ctx, uri, body, redisut.StaticFilesTTL)
    }

    ctx.String(resp.StatusCode, body)
}

func ProxyPost(ctx *gin.Context, httpClient *httput.HTTPClient) {
    uri := ctx.Request.URL.String()

    if strings.Contains(uri, "/internal/security/") { // パスワード等プロフィールの変更を禁止
        ctx.Status(http.StatusForbidden)
        return
    }

    if strings.HasPrefix(uri, "/s/") && !strings.Contains(uri, "/yourspace/") { // FIXME yourspace スペース以外の表示を禁止
        ctx.Status(http.StatusForbidden)
        return
    }

    headers := make(map[string]string)
    headers["Authorization"] = Authorization
    if ctx.Request.Header.Get("kbn-version") != "" {
        headers["kbn-version"] = ctx.Request.Header.Get("kbn-version")
    }
    if ctx.Request.Header.Get("User-Agent") != "" {
        headers["User-Agent"] = ctx.Request.Header.Get("User-Agent")
    }
    if ctx.Request.Header.Get("Content-Length") != "" {
        headers["Content-Length"] = ctx.Request.Header.Get("Content-Length")
    }
    if ctx.Request.Header.Get("Content-Type") != "" {
        headers["Content-Type"] = ctx.Request.Header.Get("Content-Type")
    }
    if ctx.Request.Header.Get("Origin") != "" {
        headers["Origin"] = replaceDomain(ctx.Request.Header.Get("Origin"))
    }
    if ctx.Request.Header.Get("Referer") != "" {
        headers["Referer"] = replaceDomain(ctx.Request.Header.Get("Referer"))
    }

    resp, err := httpClient.PostBodyURLResponse(ctx, KibanaURL+uri, ctx.Request.Body, headers)
    if err != nil {
        ctx.Status(http.StatusInternalServerError)
        return
    }

    if resp.Header.Get("Location") != "" {
        ctx.Redirect(resp.StatusCode, resp.Header.Get("Location"))
        return
    }
    if resp.Header.Get("kbn-license-sig") != "" {
        ctx.Writer.Header().Add("kbn-license-sig", resp.Header.Get("kbn-license-sig"))
    }
    if resp.Header.Get("kbn-name") != "" {
        ctx.Writer.Header().Add("kbn-name", resp.Header.Get("kbn-name"))
    }
    if resp.Header.Get("kbn-xpack-sig") != "" {
        ctx.Writer.Header().Add("kbn-xpack-sig", resp.Header.Get("kbn-xpack-sig"))
    }
    if resp.Header.Get("Content-Type") != "" {
        ctx.Writer.Header().Add("Content-Type", resp.Header.Get("Content-Type"))
    }
    if resp.Header.Get("x-cloud-request-id") != "" {
        ctx.Writer.Header().Add("x-cloud-request-id", resp.Header.Get("x-cloud-request-id"))
    }
    if resp.Header.Get("x-found-handling-cluster") != "" {
        ctx.Writer.Header().Add("x-found-handling-cluster", resp.Header.Get("x-found-handling-cluster"))
    }
    if resp.Header.Get("x-found-handling-instance") != "" {
        ctx.Writer.Header().Add("x-found-handling-instance", resp.Header.Get("x-found-handling-instance"))
    }
    if resp.Header.Get("x-found-handling-server") != "" {
        ctx.Writer.Header().Add("x-found-handling-server", resp.Header.Get("x-found-handling-server"))
    }

    body, err := httput.GetBody(ctx, resp.Body, nil)
    if err != nil {
        ctx.Status(http.StatusInternalServerError)
        return
    }

    ctx.String(resp.StatusCode, body)
}

(httput は GoでGET/POST を参照してください)

もちろん nginx とか立てた方が性能はいいと思います。
redis へのキャッシュも実際にファイルの更新あるかどうか関係なく時間決め打ちで適当なのでとりあえず動けばいい人向きです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

gRPC: そもそもRPC って何?

gRPC: そもそもRPC って何?

今回gRPC を学ぶ上でRPCの説明をできるようにするためにRPCとはについてご説明します。

概要

リモートプロシージャコール(RPC)は、分散コンピューティングのサブルーチンです。

RPCのリモート実装はローカル呼び出しに似ていますが、通常は同じではありません。

RPCでは通常、オブジェクト名、関数名、またはパラメーターがリモートサーバーに渡され、 サーバーは処理された結果をクライアント側に返します(要求/応答)。

RPCは、TCP、UDP、またはHTTPプロトコルを介して通信できます。

Go での実装

今回はGoで server / client を実装して体験します。
Goでは、公式ドキュメントにあるように
明示的なCodecの指定をしない限り、gobを使用してマーシャリングされます。

サーバーの実装

package main

import (
    "fmt"
    "log"
    "net"
    "net/rpc"
)

type Listener int
type Reply struct {
    Data string
}

func (l *Listener) GetLine(line []byte, reply *Reply) error {
    rv := string(line)
    fmt.Printf("Receive: %v\n", rv)
    *reply = Reply{rv}
    return nil
}

func main() {
    addy, err := net.ResolveTCPAddr("tcp", "0.0.0.0:12345")
    if err != nil {
        log.Fatal(err)
    }
    inbound, err := net.ListenTCP("tcp", addy)
    if err != nil {
        log.Fatal(err)
    }
    listener := new(Listener)
    rpc.Register(listener)
    rpc.Accept(inbound)
}

クライアントの実装

package main

import (
    "bufio"
    "log"
    "net/rpc"
    "os"
)

type Reply struct {
    Data string
}

func main() {
    client, err := rpc.Dial("tcp", "localhost:12345")
    if err != nil {
        log.Fatal(err)
    }
    in := bufio.NewReader(os.Stdin)
    for {
        line, _, err := in.ReadLine()
        if err != nil {
            log.Fatal(err)
        }
        var reply Reply
        err = client.Call("Listener.GetLine", line, &reply)
        if err != nil {
            log.Fatal(err)
        }
        log.Printf("Reply: %v, Data: %v", reply, reply.Data)
    }
}

in.ReadLine の無限ループで受け取っているので、
client起動したら、文字を入力してEnter をしてください。

実行結果

clientからCallすると、サーバーで定義したGetLine 関数がcall されることがわかります。

image.png

REPO

今回の実装サンプルは以下に残しておきます。
https://github.com/locona/what-is-rpc-go

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Go言語でのインターフェースの遅さ

TL;DR

Go 言語において:

  1. 関数のパラメータに構造体を渡したい場合、実体コピーで渡すよりポインタを使用したほうが速い。
  2. ポインタのダウンキャストはオーバーヘッドが存在する。
  3. インターフェースを介したメソッド呼び出しはかなり高コスト。

これらはどれも C/C++ や Java で同様の思い当たることがあるだろう。3 に関しては少し影響が大きいように見受けられ、パフォーマンスを目的として Go 言語を採用したのであればオブジェクト指向や責務の分担といった設計と両立が難しいコストとなるかもしれない。

なお筆者は Go 言語やランタイムの構造にあまり詳しくない点に注意。

経緯

以下のようなコードでパラメータの型 Image が比較的大きな構造体のポインタとなるインターフェースの場合にパフォーマンスはどうなのかという話がまずあり。

func (n Entity) LessThan(other Image) bool {
  return n.Value < other.(*Entity).Value
}

Go 言語でのスライス (配列) のソート実装に対して「評価関数のパラメータはポインタやインターフェースではなく実体で渡すべき」という指摘を見かけた (Go における interface は実質的にポインタと同じと認識)。これは経験的な直感に反する意見であったため、論議されていた内容からテストコードを起こしてみたところ、たしかに実体で渡す方が速いという結果が出た。

この github.com に載せたテストコードgo test -bench . -benchtime で実行すれば以下のような結果が得られるだろう。

BenchmarkQuickSort/Entity-8         1000000000         0.182 ns/op
BenchmarkQuickSort/Image-8          1000000000         0.644 ns/op
BenchmarkQuickSort/ImageRef-8       1000000000         0.631 ns/op

このケースに対する結果は実体コピーで渡すよりポインタで渡すほうが 3.5 倍ほど遅いことを示しているように見える。マジか。

その論議での見立てとしては、ポインタの多用はキャッシュのヒット率を低下させ (特にソートでは実体のメモリ上の配置が頻繁に入れ替わるため)、実体渡しによる一時的な構造体全体のメモリコピーのコストより、キャッシュを効率的に活用できないコストのほうが大きくなるのだろうということだった。

個人的に「構造体はポインタで渡すもの」という認識はメモリ制約の厳しい MS-DOS (16bit) 時代にヒューリスティックに身に着けたものだった。確かに配列全体がキャッシュに乗るような現代的な CPU では事情が違っているかもしれない。ここは設計に関わる転換点かもしれないのでもっと詳しく調べてみよう、というのがこの記事を書くに至った動機。

方法

何度か試行錯誤し、前述のテストコードは 3 つの観点に分解することが適切であろうという見立てができた。これはそのまま TL;DR の内容である。

  1. Entity Copy: 関数のパラメータを実体コピーで渡す方法とポインタで渡す方法の比較。
  2. Downcast: インターフェースをダウンキャストして使用する方法とダウンキャストしないで使用する方法の比較。
  3. Interface Method: フィールドやメソッドの参照をインターフェースに対して行うか実体に対して行うかの比較。

これらを評価するテストコードは github.com に置いてある。

結果

以下は前述のテストコードを go test -bench . -benchtime 30s で実行した結果を表している。実行時間は評価関数を math.MaxInt32=2G 回呼び出した時間、コストはポインタを使った呼び出しを 0 として実コードを鑑みて加算されているだろう想定。

No. 評価した関数 実行時間[sec/2G回] コスト
1. func(a, b Entity) bool { return a.Value < b.Value } 0.561844478 0 (コンパイラ最適化?)
2. func(a, b *Entity) bool { return a.Value < b.Value } 0.559486798 0
3. func(a, b Image) bool { return a.(Entity).Value < b.(Entity).Value } 49.346256905 Downcast + EntityCopy
4. func(a, b Image) bool { return a.(*Entity).Value < b.(*Entity).Value } 1.154817580 Downcast
5. func(a, b Entity) bool { return a.Priority() < b.Priority() } 12.049262425 EntityCopy
6. func(a, b *Entity) bool { return a.Priority() < b.Priority() } 0.627853464 0
7. func(a, b Image) bool { return a.(Entity).Priority() < b.(Entity).Priority() } 48.182371678 Downcast + EntityCopy
8. func(a, b Image) bool { return a.(*Entity).Priority() < b.(*Entity).Priority() } 1.212487740 Downcast
9. func(a, b Image) bool { return a.Priority() < b.Priority() } 206.816625555 EntityCopy + InterfaceMethod
10. func(a, b Image) bool { return a.Priority() < b.Priority() } 24.386049284 InterfaceMethod

Entity Copy 問題

構造体の実体に対するフィールドアクセスの 1 と 2 でほとんど差異が見られない一方で、メソッド呼び出しの 5 と 6 とでは 20 倍近い大きな差が出ている (構造体サイズに比例すると想定すると何倍かに意味はないが)。1 の関数では引数の構造体がポインタでも影響がないことが明らかであることを Go コンパイラが認識してポインタ呼び出しに最適化されているのではないだろうかと考え 5 と 6 の差異を実体コピーのコストの差とする。

このテストにおける実体コピーのオーバーヘッドを 5 と 6 の差÷2 の $t_e=5.71$ [sec] としておく (多分パラメータ 2 つのコピーコストだろう)。

Downcast 問題

6 と 8 の差異からダウンキャストによるコストが見られる。Go 言語はダウンキャストでの変換の妥当性を実行時に検証しており、これは Java で ClassCastException を発生させるチェックや C/C++ での dynamic_cast のような実行時情報評価と類似したコストであろう。2 と 4 の差異÷2からこのコストを $t_d=0.29$ [sec] と仮置く。これは構造体のサイズには依存しないだろう。

3 と 7 は非常に大きな時間がかかっているが、これはキャスト先が実体であるためにダウンキャストのコストに加えてメモリコピーのコストが加算されているものと推測される。$t_3 \simeq t_4 + t_e \times 8$ とすると実体コピー 4 回分が加算されているように見える。

Interface Method 問題

9 と 10 は同じコードだが、実際にパラメータとして渡している値が構造体の実体そのものか構造体のポインタかの違いがある (つまり 9 は Entity Copy 問題を含んでいる)。6 と 10 を比較すると、同じメソッドであってもインターフェースに対するメソッド呼び出しのほうに非常に大きなコストが発生していることが分かる。6 と 10 の差÷2より $t_i=16.88$ [sec] と仮置く。これは構造体のサイズには依存しないだろう。

例えば Java ではインターフェースに対するメソッドの呼び出しは若干コストが高いことが知られている。これは、Java で複数のインターフェースを implements する事が可能であるため仮想関数テーブル内の該当メソッドの位置をコンパイル時に決定できず、メソッド呼び出しのたびに仮想関数テーブルを探す必要があるためだ。いまのところ Go でも同様のことを行っているのだろうという予想だが、詳しく知っている人が居たら教えてほしいところ。

とにかく、Go 言語ではインターフェースのメソッド呼び出しに関しては (Java における差異と比較しても) 負荷が大きいようである。パフォーマンスが重要な部分ではインターフェースを使ったオブジェクト指向設計や責務の分担といった上位概念の導入は Go 言語では避ける必要があるように見える。

9 のケースがかなり大きなコストである理由は分からない。

結論

基準ケースを $t_2=0.56$ [sec] とする。実体コピー $t_e=5.71$ [sec] は 10 倍以上だが構造体のサイズに依存するコスト (このテストコードでは構造体サイズ 108 バイト) なので一概には比較できない。ただ構造体サイズが 64bit/32bit を超えるなら実体コピーを伴う呼び出しよりポインタでの呼び出しの方が速いだろう。ダウンキャスト $t_d=0.29$ は基準ケースの 1/2 も低く無視できるケースもあるだろう。しかしインターフェースのメソッド呼び出し $t_i=16.88$ [sec] は 30 倍もあり大きな影響となりうる。

まあ、一例での絶対値を用いた比較はあまり意味がないが、今回の結果として Go プログラミングで注意すべき優先順位は以下のようになるのではないだろうか。

Interface Method の使用 >> Entity Copy の発生 > [trivialの壁] > Downcast

附録

元々この検証は実体コピーによる呼び出しとポインタによる呼び出しの差異を調べることから始まった。本当にキャッシュヒットが関係するのであれば C/C++ でも同じ結果になると考えて go と C とで作成したバブルソートで比較していた。

go で実装したテストコードはポインタを使用した方が有意に速かった。

$ go test -bench .
goos: darwin
goarch: amd64
BenchmarkBubbleSort/CallByValue-8         1000000000         0.796 ns/op
BenchmarkBubbleSort/CallByReference-8     1000000000         0.417 ns/op

C で実装したテストコードでも同様にポインタを使用したほうが速かった。

clang$ gcc main.c -o main
clang$ ./main
by value    : 2.129106[sec]
by reference: 0.464097[sec]

どれほどの差があるかは置いておくとして、大きな構造体のコピーを伴う関数呼び出しよりもポインタを使用した呼び出しのほうが高速であることは明らかなようである。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

S3へファイルをアップロードするLambda関数をSAMで書く

はじめに

この記事は、AWS SAMを用いてファイルをS3にアップロードするLambda関数を作成する記事です。

やりたいこと

今回は、何かしらのファイル(本記事ではJSON)をS3にアップロードする処理を、AWS SAMを用いてCloutWatchイベントから定期的に発火させる、ということをやっていきます。
スクリーンショット 2020-04-05 23.50.41.png

開発環境

  • macOS Catalina
  • vim
  • Golang (1.12.6)
  • aws-sam-cli
  • Minio

SAMの設定

今回はAWS SAMというAWSが提供しているオープンソースフレームワークを用いてLambda関数の環境を構築します。

SAMを使うことによって、構成管理やソースコードのアップデートやデプロイなどをコマンド1つで完了することが出来るのでとても便利です。

今回はAPI-Gatewayなどは使用せずLambda関数のみを作成するだけなのでSAMの恩恵はちょっと感じられにくいですが、AWSを使って何か開発をやってみようと考えている人は是非導入してみましょう。

SAMの導入手順は公式ドキュメントやそれを分かりやすく解説している記事があるのでこの記事では省略します。

実装してみる

ここからは、SAMの設定などを既に完了しており、Lambda関数を作成できている前提でお話しします。

今回実装したリポジトリはこちらです。

ファイル構成

├── Makefile <-- ローカル実行コマンドなどをまとめたファイル
├── README.md                   
├── src <-- Lambda関数のソースコード
    └── fetcher
        └── urlFetcher.go <-- URLからJSONを取得する処理を記述したファイル
    ├── handler
        └── handler.go <-- mainパッケージから実行される処理
    ├── json
        └── jsonWorker.go <-- JSONファイルの作成や書き出しなどを行う処理
    ├── s3
        └── uploader.go <-- Sessionの生成やアップロードを行う処理
├── docker-compose.yaml <-- ローカル開発用のMinioコンテナの情報
└── template.yaml <-- Lambdaなどの全体構成を指定するファイル

SAMテンプレートの設定

今回作成するLambda関数のインフラストラクチャ及びコンポーネントの設定をします。
設定はtemplate.yamlに全て書き込みます。buildやdeployをする際、このファイルを元にLambda関数が作成されます。
詳しいパラメータについては公式ドキュメントを参考にしてください。
例えば、1時間に1回発火するLambda関数を作成する場合の設定は以下の通りです。

Resources:
   SampleFunction:
     Type: AWS::Serverless::Function
     Properties:
       CodeUri: src/
       Handler: handler
       FunctionName: SampleFunction
       Runtime: go1.x
       Tracing: Active
       Events:
         CatchAll:
           Type: Schedule
           Properties:
             Schedule: rate(1 hour)
             Name: one-hour-rule
             Description: Exec each one-hour
             Enabled: True
       Environment:
         Variables:
           PARAM1: VALUE

Minioの環境構築

今回、S3へのアップロードを行うLambdaの開発を行うわけですが、ローカルでの開発を行う際に実際のバケットにアクセスしてファイルを追加したり削除したりするのはちょっと怖いですよね。

そこで今回はMinioというクラウドストレージサーバをローカルではS3のバケットに見立てて開発をしていきます。

これを使うことでlocalhost上でバケットの作成やファイルの追加・削除をすることが出来るので安心です。

MinioはDockerイメージを公開しているので、Minioの環境構築自体はdocker-composeファイルを作成するだけです。

version: "2"
services:
  minio:
    image: minio/minio
    ports:
      - "9000:9000"
    command: [server, /data]
    environment:
      - "MINIO_ACCESS_KEY=dummy"
      - "MINIO_SECRET_KEY=dummy"
      - "MINIO_REGION=ap-northeast-1"
    restart: always
networks:
  default:
    external:
      name: sample_network

特に注目したいのが environmentです。

通常、S3のバケットにアクセスするにはIAMロールかAssume RoleのAccess Key IDSecret Keyが必要になります。ここでは、Minioコンテナにアクセスする際のAccess Key IDSecret Keyを設定できます。

後ほど書きますが、この情報がCredentialを作成するときに必要となります。

environment/:
      - "MINIO_ACCESS_KEY=dummy"
      - "MINIO_SECRET_KEY=dummy"
      - "MINIO_REGION=ap-northeast-1"

一度 docker-compose up -dを実行してMinioコンテナが立ち上がるか確かめてみましょう。

docker ps                                                                                      [develop]
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
267811ea93f7        minio/minio         "/usr/bin/docker-ent…"   3 weeks ago         Up 3 weeks          0.0.0.0:9000->9000/tcp   samplefunction_minio_1

立ち上がっていることを確認できたら、実際にアクセスしてみましょう。
今回の場合だと、localhost:9000になります。
ちなみに、画面上からもバケットの作成やファイルのアップロードが可能ですので、予めバケットを作成するときは画面上からも行うことが出来ます。
今回私は画面上から作成しました。

スクリーンショット 2020-04-05 23.53.45.png

ファイルに書き込みたい情報の準備

まずはファイルに書き込みたい情報を準備しましょう。
今回はサンプルとしてAPIなどを叩いて得られた情報をJSONに書き込んでいきます。
情報は、鉄道遅延情報のJSONから取得させていただきました。
? お約束:ローカルでこのサンプルをそのまま実行したりする場合は、鉄道遅延情報のJSONお約束を必ず読んで理解した上で利用しましょう。決してアクセスしまくって迷惑をかけたりしないでください。 ?

package urlFetcher

import (
    "io/ioutil"
    "log"
    "net/http"
)

var (
    url = "https://tetsudo.rti-giken.jp/free/delay.json"
)

/**
 * Fetch Bytes from Web site
 */
func GetBytesFromUrl() []byte {
    resp, err := http.Get(url)
    if err != nil {
        log.Fatal("Can not get delay list! Error: %v", err)
    }
    defer resp.Body.Close()

    delayList, _ := ioutil.ReadAll(resp.Body)

    log.Printf("Succeeded to get Delay-list!")
    return delayList
}

基本ここは「golang URL get」などで調べてば参考記事がたくさん出てきます。

JSONファイルの作成などを行う前に、書き込むものを準備する必要があるのでここのfunctionではそれを作成している処理になります。

JSONの取得と保存

JSONファイルに書き込むものが準備できたところで、それをアップロードするためにファイルにする処理を記述します。流れとしては以下です。

  • tempディレクトリにJSONファイルを作成するためのディレクトリと空のファイルを作成
  • 作成した空のファイルにByte配列を書き込む

という感じです。

package JsonWorker

import (
    "fmt"
    "os"
)

var (
    tempDir    = "/tmp/json/"
    bucketName = "delay-list"
    key        = "delay-list.json"
)

/**
 * Create empty JSON file on temp file and write bytes
 */
func CreateJSON(byteInfo []byte) error {
    if byteInfo == nil {
        return fmt.Errorf("create JSON error: %s", "nil bytes was given")
    }

    if err := os.MkdirAll(tempDir, 0777); err != nil {
        return err
    }

    file, err := os.Create(tempDir + key)
    if err != nil {
        return err
    }

    _, err = file.Write(byteInfo)
    if err != nil {
        return err
    }

    if isExist := isExistTempFile(tempDir); isExist != true {
        return fmt.Errorf("Temp file does not exist")
    }

    return nil
}

/**
 * Verify if exist temp file
 */
func isExistTempFile(tempFile string) bool {
    _, err := os.Stat(tempFile)
    return !os.IsNotExist(err)
}

JSONのアップロード

tempファイルに作成されたJSONファイルを実際にS3にアップロードする処理を書きます。

今回まずは、ローカルのMinioコンテナで作成したバケットにアップロードしてみましょう。

package s3Uploader

import (
    "log"
    "os"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/credentials"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3/s3manager"
)

var (
    bucketName = "sample"
    key        = "sample.json"
)

/**
 * Yield new session to upload file to S3 bucket
 */
func Upload(jsonFile *os.File) error {

    var sess *session.Session

        /* Yield credential for local */
    log.Printf("Start process getting credential as a local")
    credential := credentials.NewStaticCredentials(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), "")
    sess, _ = session.NewSession(&aws.Config{
        Credentials:      credential,
        Region:           aws.String("ap-northeast-1"),
        Endpoint:         aws.String("http://172.18.0.2:9000"),
        S3ForcePathStyle: aws.Bool(true),
   })

    _, err := sess.Config.Credentials.Get()
    if err != nil {
        log.Fatal("Load Credential File Error:  %+v\n", err)
    }

    uploader := s3manager.NewUploader(sess)

    // Upload File With Custom Session
    _, err = uploader.Upload(&s3manager.UploadInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(key),
        Body:   jsonFile,
    })
    if err != nil {
        return err
    }

    log.Printf("Succeeded to upload delay list!")
    return nil
}

ここで難しいのが、Credentialの読み込みSessionの作成です。

通常AWSのサービスを利用する場合には認証情報が必要です。今回行うS3へのアップロードも例外ではありません。

今回はNewStaticCredentialsメソッドを使って認証情報を取得します。今はMinioへアクセスするために先ほどMinioのコンテナを作る際に設定したAccess Key IDSecret Keyを使用します。os.Getenv()で取得することが可能です。

credential := credentials.NewStaticCredentials(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), "")

取得した認証情報をもとにS3にアクセスするためのSessionを生成します。ローカルなのでパラメータが多いですw

sess, _ = session.NewSession(&aws.Config{
        Credentials:      credential,
        Region:           aws.String("ap-northeast-1"),
        Endpoint:         aws.String("http://172.18.0.2:9000"),
        S3ForcePathStyle: aws.Bool(true),
   })

生成したSessionをもとにUploaderを生成して実際にアップロードを行います。

uploader := s3manager.NewUploader(sess)

    // Upload File With Custom Session
    _, err = uploader.Upload(&s3manager.UploadInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(key),
        Body:   jsonFile,
    })

ローカルでの実行

ここまでできたら、実際に動かしてみましょう。まずはソースコードをbuildする必要があります。

sam build

buildが完了したらlocalで実行するコマンドを叩きましょう。

sam local invoke SampleFunction

sam local invokeコマンドについてはオプションがたくさんあるので適宜追加をしましょう。
オプションについては、こちらを参考にしてください。
Dockerネットワークの指定リージョンの指定Profileの指定を行う場合は以下のようになります。
ネットワークの指定は、さきほど作成したMinioコンテナと同じネットワークに所属させる必要があるため、同じネットワーク名を指定してあげましょう。

sam local invoke SampleFunction \
     --region ap-northeast-1 \
     --docker-network sample_network \
     --profile minio_test

何もエラーが起きずに実行が終了したら、Minioにアクセスしてファイルがアップロードされているか確認してみましょう。

本番環境とローカル環境の区別について

ローカルでの実行が完了し、いざデプロイ…といきたいところですが、今のままではMinioへのアップロードしかできていないため、このままデプロイをしてもアップロード処理でエラーになってしまいます。

ですので次は、実際にデプロイ後の動作とローカルでの動作を分ける処理を記述します。

先ほどのuploader.goのCredentialとSessionの生成の処理に分岐を加えます。

if os.Getenv("AWS_SAM_LOCAL") == "true" {
        /* Yield credential for local */
        log.Printf("Start process getting credential as a local")
        credential := credentials.NewStaticCredentials(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), "")
        sess, _ = session.NewSession(&aws.Config{
            Credentials:      credential,
            Region:           aws.String("ap-northeast-1"),
            Endpoint:         aws.String("http://172.18.0.2:9000"),
            S3ForcePathStyle: aws.Bool(true),
        })
} else {
        /* Yield credential for production */
        log.Printf("Start process getting credential as a production")
        sess, _ = session.NewSession(&aws.Config{
            Region:           aws.String("ap-northeast-1"),
            S3ForcePathStyle: aws.Bool(true),
        })
    }

注目すべきは、os.Getenv("AWS_SAM_LOCAL")です。

SAMで実行された場合、環境変数AWS_SAM_LOCALtrueがセットされるため、Lambda関数がローカルから実行されたかどうかはこいつで判定することが出来ます。

そしてCredentialの生成についてですが、Minioでのローカル環境の場合は環境変数からAccess Key IDSecret Keyを読み込みましたが、本番環境の場合はローカルシステム上の認証情報を読み込みます。

ローカルでの認証情報は~/.aws.credentialsにあると思います。

設定がされていない方は、AWSの公式ドキュメントを参照ください。確かこれを使うセキュリティ上の理由などについてもどこかに書かれていた覚えがあります…(知っている方はコメントください(`・ω・´)ゞ)

そのため、本番環境の場合にCredentialの生成処理などは特に書く必要はありません。(Assume Roleをしている場合は別です!)。基本的にリージョンの指定だけで大丈夫です。

/* Yield credential for production */
        log.Printf("Start process getting credential as a production")
        sess, _ = session.NewSession(&aws.Config{
            Region:           aws.String("ap-northeast-1"),
            S3ForcePathStyle: aws.Bool(true),
        })

できたらもう一度実行して、ローカルでのデグレ検証を行います。

問題なければ実際にデプロイしましょう!以下のコマンドを実行します。初回のデプロイは--guidedをつけましょう。

sam deploy --guided

これを実行し、実際にAWSマネジメントコンソールを開いてLambda関数が増えていれば成功です!

あとはCloudWatch Eventの設定を確認しつつモニタリングからログを追って成功しているかどうかを確認しましょう!

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Golang] S3へファイルをアップロードするLambda関数をSAMで書く

はじめに

この記事は、AWS SAMを用いてファイルをS3にアップロードするLambda関数を作成する記事です。

やりたいこと

今回は、何かしらのファイル(本記事ではJSON)をS3にアップロードする処理を、AWS SAMを用いてCloutWatchイベントから定期的に発火させる、ということをやっていきます。
スクリーンショット 2020-04-05 23.50.41.png

開発環境

  • macOS Catalina
  • vim
  • Golang (1.12.6)
  • aws-sam-cli
  • Minio

SAMの設定

今回はAWS SAMというAWSが提供しているオープンソースフレームワークを用いてLambda関数の環境を構築します。

SAMを使うことによって、構成管理やソースコードのアップデートやデプロイなどをコマンド1つで完了することが出来るのでとても便利です。

今回はAPI-Gatewayなどは使用せずLambda関数のみを作成するだけなのでSAMの恩恵はちょっと感じられにくいですが、AWSを使って何か開発をやってみようと考えている人は是非導入してみましょう。

SAMの導入手順は公式ドキュメントやそれを分かりやすく解説している記事があるのでこの記事では省略します。

実装してみる

ここからは、SAMの設定などを既に完了しており、Lambda関数を作成できている前提でお話しします。

今回実装したリポジトリはこちらです。

ファイル構成

├── Makefile <-- ローカル実行コマンドなどをまとめたファイル
├── README.md                   
├── src <-- Lambda関数のソースコード
    └── fetcher
        └── urlFetcher.go <-- URLからJSONを取得する処理を記述したファイル
    ├── handler
        └── handler.go <-- mainパッケージから実行される処理
    ├── json
        └── jsonWorker.go <-- JSONファイルの作成や書き出しなどを行う処理
    ├── s3
        └── uploader.go <-- Sessionの生成やアップロードを行う処理
├── docker-compose.yaml <-- ローカル開発用のMinioコンテナの情報
└── template.yaml <-- Lambdaなどの全体構成を指定するファイル

SAMテンプレートの設定

今回作成するLambda関数のインフラストラクチャ及びコンポーネントの設定をします。
設定はtemplate.yamlに全て書き込みます。buildやdeployをする際、このファイルを元にLambda関数が作成されます。
詳しいパラメータについては公式ドキュメントを参考にしてください。
例えば、1時間に1回発火するLambda関数を作成する場合の設定は以下の通りです。

Resources:
   SampleFunction:
     Type: AWS::Serverless::Function
     Properties:
       CodeUri: src/
       Handler: handler
       FunctionName: SampleFunction
       Runtime: go1.x
       Tracing: Active
       Events:
         CatchAll:
           Type: Schedule
           Properties:
             Schedule: rate(1 hour)
             Name: one-hour-rule
             Description: Exec each one-hour
             Enabled: True
       Environment:
         Variables:
           PARAM1: VALUE

Minioの環境構築

今回、S3へのアップロードを行うLambdaの開発を行うわけですが、ローカルでの開発を行う際に実際のバケットにアクセスしてファイルを追加したり削除したりするのはちょっと怖いですよね。

そこで今回はMinioというクラウドストレージサーバをローカルではS3のバケットに見立てて開発をしていきます。

これを使うことでlocalhost上でバケットの作成やファイルの追加・削除をすることが出来るので安心です。

MinioはDockerイメージを公開しているので、Minioの環境構築自体はdocker-composeファイルを作成するだけです。

version: "2"
services:
  minio:
    image: minio/minio
    ports:
      - "9000:9000"
    command: [server, /data]
    environment:
      - "MINIO_ACCESS_KEY=dummy"
      - "MINIO_SECRET_KEY=dummy"
      - "MINIO_REGION=ap-northeast-1"
    restart: always
networks:
  default:
    external:
      name: sample_network

特に注目したいのが environmentです。

通常、S3のバケットにアクセスするにはIAMロールかAssume RoleのAccess Key IDSecret Keyが必要になります。ここでは、Minioコンテナにアクセスする際のAccess Key IDSecret Keyを設定できます。

後ほど書きますが、この情報がCredentialを作成するときに必要となります。

environment/:
      - "MINIO_ACCESS_KEY=dummy"
      - "MINIO_SECRET_KEY=dummy"
      - "MINIO_REGION=ap-northeast-1"

一度 docker-compose up -dを実行してMinioコンテナが立ち上がるか確かめてみましょう。

docker ps                                                                                      [develop]
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
267811ea93f7        minio/minio         "/usr/bin/docker-ent…"   3 weeks ago         Up 3 weeks          0.0.0.0:9000->9000/tcp   samplefunction_minio_1

立ち上がっていることを確認できたら、実際にアクセスしてみましょう。
今回の場合だと、localhost:9000になります。
ちなみに、画面上からもバケットの作成やファイルのアップロードが可能ですので、予めバケットを作成するときは画面上からも行うことが出来ます。
今回私は画面上から作成しました。

スクリーンショット 2020-04-05 23.53.45.png

ファイルに書き込みたい情報の準備

まずはファイルに書き込みたい情報を準備しましょう。
今回はサンプルとしてAPIなどを叩いて得られた情報をJSONに書き込んでいきます。
情報は、鉄道遅延情報のJSONから取得させていただきました。
? お約束:ローカルでこのサンプルをそのまま実行したりする場合は、鉄道遅延情報のJSONお約束を必ず読んで理解した上で利用しましょう。決してアクセスしまくって迷惑をかけたりしないでください。 ?

package urlFetcher

import (
    "io/ioutil"
    "log"
    "net/http"
)

var (
    url = "https://tetsudo.rti-giken.jp/free/delay.json"
)

/**
 * Fetch Bytes from Web site
 */
func GetBytesFromUrl() []byte {
    resp, err := http.Get(url)
    if err != nil {
        log.Fatal("Can not get delay list! Error: %v", err)
    }
    defer resp.Body.Close()

    delayList, _ := ioutil.ReadAll(resp.Body)

    log.Printf("Succeeded to get Delay-list!")
    return delayList
}

基本ここは「golang URL get」などで調べてば参考記事がたくさん出てきます。

JSONファイルの作成などを行う前に、書き込むものを準備する必要があるのでここのfunctionではそれを作成している処理になります。

JSONの取得と保存

JSONファイルに書き込むものが準備できたところで、それをアップロードするためにファイルにする処理を記述します。流れとしては以下です。

  • tempディレクトリにJSONファイルを作成するためのディレクトリと空のファイルを作成
  • 作成した空のファイルにByte配列を書き込む

という感じです。

package JsonWorker

import (
    "fmt"
    "os"
)

var (
    tempDir    = "/tmp/json/"
    bucketName = "delay-list"
    key        = "delay-list.json"
)

/**
 * Create empty JSON file on temp file and write bytes
 */
func CreateJSON(byteInfo []byte) error {
    if byteInfo == nil {
        return fmt.Errorf("create JSON error: %s", "nil bytes was given")
    }

    if err := os.MkdirAll(tempDir, 0777); err != nil {
        return err
    }

    file, err := os.Create(tempDir + key)
    if err != nil {
        return err
    }

    _, err = file.Write(byteInfo)
    if err != nil {
        return err
    }

    if isExist := isExistTempFile(tempDir); isExist != true {
        return fmt.Errorf("Temp file does not exist")
    }

    return nil
}

/**
 * Verify if exist temp file
 */
func isExistTempFile(tempFile string) bool {
    _, err := os.Stat(tempFile)
    return !os.IsNotExist(err)
}

JSONのアップロード

tempファイルに作成されたJSONファイルを実際にS3にアップロードする処理を書きます。

今回まずは、ローカルのMinioコンテナで作成したバケットにアップロードしてみましょう。

package s3Uploader

import (
    "log"
    "os"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/credentials"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3/s3manager"
)

var (
    bucketName = "sample"
    key        = "sample.json"
)

/**
 * Yield new session to upload file to S3 bucket
 */
func Upload(jsonFile *os.File) error {

    var sess *session.Session

        /* Yield credential for local */
    log.Printf("Start process getting credential as a local")
    credential := credentials.NewStaticCredentials(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), "")
    sess, _ = session.NewSession(&aws.Config{
        Credentials:      credential,
        Region:           aws.String("ap-northeast-1"),
        Endpoint:         aws.String("http://172.18.0.2:9000"),
        S3ForcePathStyle: aws.Bool(true),
   })

    _, err := sess.Config.Credentials.Get()
    if err != nil {
        log.Fatal("Load Credential File Error:  %+v\n", err)
    }

    uploader := s3manager.NewUploader(sess)

    // Upload File With Custom Session
    _, err = uploader.Upload(&s3manager.UploadInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(key),
        Body:   jsonFile,
    })
    if err != nil {
        return err
    }

    log.Printf("Succeeded to upload delay list!")
    return nil
}

ここで難しいのが、Credentialの読み込みSessionの作成です。

通常AWSのサービスを利用する場合には認証情報が必要です。今回行うS3へのアップロードも例外ではありません。

今回はNewStaticCredentialsメソッドを使って認証情報を取得します。今はMinioへアクセスするために先ほどMinioのコンテナを作る際に設定したAccess Key IDSecret Keyを使用します。os.Getenv()で取得することが可能です。

credential := credentials.NewStaticCredentials(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), "")

取得した認証情報をもとにS3にアクセスするためのSessionを生成します。ローカルなのでパラメータが多いですw

sess, _ = session.NewSession(&aws.Config{
        Credentials:      credential,
        Region:           aws.String("ap-northeast-1"),
        Endpoint:         aws.String("http://172.18.0.2:9000"),
        S3ForcePathStyle: aws.Bool(true),
   })

生成したSessionをもとにUploaderを生成して実際にアップロードを行います。

uploader := s3manager.NewUploader(sess)

    // Upload File With Custom Session
    _, err = uploader.Upload(&s3manager.UploadInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(key),
        Body:   jsonFile,
    })

ローカルでの実行

ここまでできたら、実際に動かしてみましょう。まずはソースコードをbuildする必要があります。

sam build

buildが完了したらlocalで実行するコマンドを叩きましょう。

sam local invoke SampleFunction

sam local invokeコマンドについてはオプションがたくさんあるので適宜追加をしましょう。
オプションについては、こちらを参考にしてください。
Dockerネットワークの指定リージョンの指定Profileの指定を行う場合は以下のようになります。
ネットワークの指定は、さきほど作成したMinioコンテナと同じネットワークに所属させる必要があるため、同じネットワーク名を指定してあげましょう。

sam local invoke SampleFunction \
     --region ap-northeast-1 \
     --docker-network sample_network \
     --profile minio_test

何もエラーが起きずに実行が終了したら、Minioにアクセスしてファイルがアップロードされているか確認してみましょう。

本番環境とローカル環境の区別について

ローカルでの実行が完了し、いざデプロイ…といきたいところですが、今のままではMinioへのアップロードしかできていないため、このままデプロイをしてもアップロード処理でエラーになってしまいます。

ですので次は、実際にデプロイ後の動作とローカルでの動作を分ける処理を記述します。

先ほどのuploader.goのCredentialとSessionの生成の処理に分岐を加えます。

if os.Getenv("AWS_SAM_LOCAL") == "true" {
        /* Yield credential for local */
        log.Printf("Start process getting credential as a local")
        credential := credentials.NewStaticCredentials(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), "")
        sess, _ = session.NewSession(&aws.Config{
            Credentials:      credential,
            Region:           aws.String("ap-northeast-1"),
            Endpoint:         aws.String("http://172.18.0.2:9000"),
            S3ForcePathStyle: aws.Bool(true),
        })
} else {
        /* Yield credential for production */
        log.Printf("Start process getting credential as a production")
        sess, _ = session.NewSession(&aws.Config{
            Region:           aws.String("ap-northeast-1"),
            S3ForcePathStyle: aws.Bool(true),
        })
    }

注目すべきは、os.Getenv("AWS_SAM_LOCAL")です。

SAMで実行された場合、環境変数AWS_SAM_LOCALtrueがセットされるため、Lambda関数がローカルから実行されたかどうかはこいつで判定することが出来ます。

そしてCredentialの生成についてですが、Minioでのローカル環境の場合は環境変数からAccess Key IDSecret Keyを読み込みましたが、本番環境の場合はローカルシステム上の認証情報を読み込みます。

ローカルでの認証情報は~/.aws.credentialsにあると思います。

設定がされていない方は、AWSの公式ドキュメントを参照ください。確かこれを使うセキュリティ上の理由などについてもどこかに書かれていた覚えがあります…(知っている方はコメントください(`・ω・´)ゞ)

そのため、本番環境の場合にCredentialの生成処理などは特に書く必要はありません。(Assume Roleをしている場合は別です!)。基本的にリージョンの指定だけで大丈夫です。

/* Yield credential for production */
        log.Printf("Start process getting credential as a production")
        sess, _ = session.NewSession(&aws.Config{
            Region:           aws.String("ap-northeast-1"),
            S3ForcePathStyle: aws.Bool(true),
        })

できたらもう一度実行して、ローカルでのデグレ検証を行います。

問題なければ実際にデプロイしましょう!以下のコマンドを実行します。初回のデプロイは--guidedをつけましょう。

sam deploy --guided

これを実行し、実際にAWSマネジメントコンソールを開いてLambda関数が増えていれば成功です!

あとはCloudWatch Eventの設定を確認しつつモニタリングからログを追って成功しているかどうかを確認しましょう!

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む