技術向上

プログラミングの学び、気になるテクノロジーやビジネストレンドを発信

Go × Cloud Functionsでメール送信機能を開発【Go】【GCP】【CI/CD】

概要

GoとCloud Functions、さらにSend Gridというメールサービスを使って、HTTPリクエストからメールを送信できる機能を開発します。

さらに、関数を記載したGithubリポジトリへmasterブランチへのマージがあれば、Cloud Funcitonsへデプロイして関数を更新します。


使用技術

  • Go
  • Cloud Functions (HTTP関数)
  • Send Grid
  • Cloud Build


Send Gridの登録

新規登録後、公式ドキュメントに従い、API_KEYを発行し、控えておきます。

メール機能の開発

SendGrid for Goのライブラリを使用して、メールを送信する関数を開発します。

まずはRequest Bodyとして受け取る、メール送信に必要な情報を格納するstructを定義します。

type postParam struct {
    From         string `json:"from" validate:"required,email"`
    FromName     string `json:"from-name" validate:"required"`
    To           string `json:"to" validate:"required,email"`
    ToName       string `json:"to-name" validate:"required"`
    Subject      string `json:"subject" validate:"required"`
    PlainContent string `json:"plain-content" validate:"required"`
    HTMLContent  string `json:"html-content" validate:"required,html"`
}

ライブラリvalidatorを使って、受け取る情報を検証しています(validate)。


Cloud Functionsでは、変数をグローバル定義にすることで、その変数の値がキャッシュされ、二回目以降の関数呼び出しで再利用されます。

これを利用して、Send Gridとの接続を確立する変数をグローバルスコープに定義するとともに、 init()関数で、その接続を確立します。init()関数はコールドスタートの時のみ実行されます。これによって、コストの高い外部との接続を関数の都度実行する必要がなくなります。

API_KEYはデプロイ時に設定する環境変数から読み取っています。

var client *sendgrid.Client

func init() {
    apiKey := os.Getenv("SENDGRID_API_KEY")
    if apiKey == "" {
        panic("empty SENDGRID_API_KEY")
    }
    client = sendgrid.NewSendClient(apiKey)
}


メール機能の本丸です。

func SendMail(w http.ResponseWriter, r *http.Request) {
    //CORS
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Set("Access-Control-Allow-Methods", "POST")
    w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
    if r.Method == http.MethodOptions {
        w.WriteHeader(http.StatusNoContent)
        return
    }

    if r.Method != http.MethodPost {
        http.Error(w, "Method should be POST.", http.StatusMethodNotAllowed)
        fmt.Printf("You requested method %s, but method should be POST.", r.Method)
        return
    }

    var param postParam
    if err := json.NewDecoder(r.Body).Decode(&param); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        fmt.Printf("Invalid request body: %s", err.Error())
        return
    }

    v := validator.New()
    if err := v.Struct(param); err != nil {
        http.Error(w, "Validation error.", http.StatusBadRequest)

        errMsg := []string{}
        for _, err := range err.(validator.ValidationErrors) {
            var errField string
            // deal with err
            switch err.Field() {
            case "From":
                errField = "'from (送信元アドレス)'"
            case "FromName":
                errField = "'from-name (送信元名)'"
            case "To":
                errField = "'to (送信先アドレス)'"
            case "ToName":
                errField = "'to-name (送信先名)'"
            case "Subject":
                errField = "'subject (件名)'"
            case "PlainContent":
                errField = "'plain-content (プレーンテキスト本文)'"
            case "HTMLContent":
                errField = "'html-content (HTML形式本文)'"
            }
            errMsg = append(errMsg, fmt.Sprintf("%s validation error. Specified: %s, Actual: %s", errField, err.Tag(), err.Value()))
        }
        fmt.Printf("Validation error: %s", strings.Join(errMsg, "\n"))
        return
    }

    from := mail.NewEmail(param.FromName, param.From)
    to := mail.NewEmail(param.ToName, param.To)
    message := mail.NewSingleEmail(from, param.Subject, to, param.PlainContent, param.HTMLContent)

    response, err := client.Send(message)
    if err != nil {
        http.Error(w, "SendGrid send error.", http.StatusServiceUnavailable)
        fmt.Printf("SendGrid send error: %s", err.Error())
        return
    }

    for k, v := range response.Headers {
        for _, vv := range v {
            w.Header().Add(k, vv)
        }
    }
    w.WriteHeader(response.StatusCode)
    fmt.Fprintln(w, response.Body)
}


序盤でCORSの対応と、プリフライトリクエストの対応、メソッドの確認を行なっています。

jsonメソッドを用いて、クライアントから送信されたRequest Bodyの値を定義したstructにデコードしています。
その際に、validateに定義した規則に従っていない値ならば、その内容に応じたエラーを返します。

なお、http.Errorを返すのは非常に重要です。
こちらにも書かれている通り、エラーレスポンスを返さない限り、Cloud Functionsは、タイムアウトするまで関数を実行し続け、その数だけ課金され続けてしまう可能性があります。

fmt.Printf()などで出力したログは、GCPコンソールのLogging UIやgcloudコマンドで確認することができます。


メール送信の記述はSend Gridの公式ドキュメントでも紹介されている一般的な書き方です。

ただし、Go版ではIssueにも上がっているように、plain text contentを必ず指定する必要があります。指定しない場合、エラーになるので注意してください。

Send GridのSend()メソッドは下記の型のResponseを返します。

type Response struct {
    StatusCode int                 // e.g. 200
    Body       string              // e.g. {"result: success"}
    Headers    map[string][]string // e.g. map[X-Ratelimit-Limit:[600]]
}


これらをhttp.ResponseWriterの各フィールドに書き込みます。

 for k, v := range response.Headers {
        for _, vv := range v {
            w.Header().Add(k, vv)
        }
    }
    w.WriteHeader(response.StatusCode)
    fmt.Fprintln(w, response.Body)


Headersの型がmap[string][]stringなので、2回for文を回す必要があります。
w.Header().Add(key, value)を使うことで、同じものを上書きしてしまうことなく、追加していくことができます。

StatusCodeはWriteHeader()メソッドを使います。

Bodyに関してはfmt.Fprintlnを使っています。
Body内容を書き込むには下記方法もありますが、

w.Write([]byte(response.Body))

この方法だと、[]byte()によって、変換に伴うメモリアロケーションが生じてしまいます。
fmt.Fprintf()は、内部でメモリを再利用しているため、メモリ効率に優れています。


全文を記載します。

今回はCloud Functionsへデプロイすることを想定しています。
package名は任意で構いません。mainパッケージも必要ありません。(というよりも、mainを指定できません。)

packageを複数用意して、デプロイする関数内で外部関数を参照することも可能ですが、
Cloud Functionsにデプロイする、という目的上、なるべくシンプルにした方が良いかもしれません。

package mail

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strings"

    "github.com/sendgrid/sendgrid-go"
    "github.com/sendgrid/sendgrid-go/helpers/mail"
    validator "gopkg.in/go-playground/validator.v9"
)

type postParam struct {
    From         string `json:"from" validate:"required,email"`
    FromName     string `json:"from-name" validate:"required"`
    To           string `json:"to" validate:"required,email"`
    ToName       string `json:"to-name" validate:"required"`
    Subject      string `json:"subject" validate:"required"`
    PlainContent string `json:"plain-content" validate:"required"`
    HTMLContent  string `json:"html-content" validate:"required,html"`
}

var client *sendgrid.Client

func init() {
    apiKey := os.Getenv("SENDGRID_API_KEY")
    if apiKey == "" {
        panic("empty SENDGRID_API_KEY")
    }
    client = sendgrid.NewSendClient(apiKey)
}

func SendMail(w http.ResponseWriter, r *http.Request) {
    //CORS
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Set("Access-Control-Allow-Methods", "POST")
    w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
    if r.Method == http.MethodOptions {
        w.WriteHeader(http.StatusNoContent)
        return
    }

    if r.Method != http.MethodPost {
        http.Error(w, "Method should be POST.", http.StatusMethodNotAllowed)
        fmt.Printf("You requested method %s, but method should be POST.", r.Method)
        return
    }

    var param postParam
    if err := json.NewDecoder(r.Body).Decode(&param); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        fmt.Printf("Invalid request body: %s", err.Error())
        return
    }

    v := validator.New()
    if err := v.Struct(param); err != nil {
        http.Error(w, "Validation error.", http.StatusBadRequest)

        errMsg := []string{}
        for _, err := range err.(validator.ValidationErrors) {
            var errField string
            // deal with err
            switch err.Field() {
            case "From":
                errField = "'from (送信元アドレス)'"
            case "FromName":
                errField = "'from-name (送信元名)'"
            case "To":
                errField = "'to (送信先アドレス)'"
            case "ToName":
                errField = "'to-name (送信先名)'"
            case "Subject":
                errField = "'subject (件名)'"
            case "PlainContent":
                errField = "'plain-content (プレーンテキスト本文)'"
            case "HTMLContent":
                errField = "'html-content (HTML形式本文)'"
            }
            errMsg = append(errMsg, fmt.Sprintf("%s validation error. Specified: %s, Actual: %s", errField, err.Tag(), err.Value()))
        }
        fmt.Printf("Validation error: %s", strings.Join(errMsg, "\n"))
        return
    }

    from := mail.NewEmail(param.FromName, param.From)
    to := mail.NewEmail(param.ToName, param.To)
    message := mail.NewSingleEmail(from, param.Subject, to, param.PlainContent, param.HTMLContent)

    response, err := client.Send(message)
    if err != nil {
        http.Error(w, "SendGrid send error.", http.StatusServiceUnavailable)
        fmt.Printf("SendGrid send error: %s", err.Error())
        return
    }

    for k, v := range response.Headers {
        for _, vv := range v {
            w.Header().Add(k, vv)
        }
    }
    w.WriteHeader(response.StatusCode)
    fmt.Fprintln(w, response.Body)
}

Cloud Functionsへ関数を登録する

Cloud Functionsの基本的な使い方はこちらに記載しました。

GoでCloud Functionsを使うには、go.modが必要です。
Go1.12では標準搭載となりますが、それ以前のver.を使っている場合には、こちらを参考にしてください。

go.modファイルがあることを確認し、先ほどのSendMail関数を記述したファイルと同じ階層で下記のようなコマンドを実行します。

$ gcloud functions deploy SendMail --runtime go111  --set-env-vars SENDGRID_API_KEY=<YOUR_API_KEY> --trigger-http --region asia-northeast1


SendMailをデプロイするにあたって、実行環境にGo 1.1.1を指定し、先ほどのコードで指定した"SENDGRID_API_KEY"を環境変数として、発行したAPI_KEYを指定します。

最後にリージョンを東京に指定していますが、明示的に指定しない場合、usリージョンになってしまうので、注意が必要です。当然のことながら、関数の呼び出し元に近いリージョンを指定することが速度的にも重要です。


Cloud Buildを使って、CDフローを整備する

関数を記載したファイルをソース管理ツールにアップロードして管理するかと思います。
関数に関係する部分に変更があったら、自動的にデプロイするフローを用意した方が楽です。
(Cloud Functionsの仕組みは、関数を含むファイルをzip形式でアップロードするというものなので、関数を更新する場合は、その後に再度デプロイをする必要があります。)

Cloud Buildの使い方はこちらで紹介しています。
今回はcloudbuild.yamlを使います。

steps:
  - name: 'gcr.io/cloud-builders/gcloud'
    args: ["functions", "deploy", "SendMail", "--runtime go111", "--set-env-vars SENDGRID_API_KEY=${_SENDGRID_API_KEY}", "--trigger-http"]


nameにgcloudを使うことを明記します。
argsは、先ほど記したgcloudコマンドと同じ内容です。
API_KEYは代入変数を指定しており、GCPのコンソール画面(Cloud Build)で、_SENDGRID_API_KEYを指定しておきます。

環境変数やリージョンは、関数名が同じであれば、都度指定する必要はないのですが、何らかの影響で、関数が削除される自体も予想できます。継続的な開発を考える上でもAPI_KEYを毎回指定するようにしています。


このcloudbuild.yamlファイルをこのプロジェクトのルートディレクトリに配置し、GCPのコンソール画面で実行対象ファイルに指定します。

f:id:tech-up:20190822164343p:plain

f:id:tech-up:20190822164404p:plain



これで、masterブランチにマージされると、同時に関数がCloud Functionsにデプロイされるようになりました。

ただし、セキュリティの観点から、API_KEYを他の方法で管理するなど工夫は必要かもしれません。



参考

Cloud Functions公式ドキュメント
Cloud Build公式ドキュメント
Send Grid公式ドキュメント
Send Grid for Go Github