20201027のGoに関する記事は4件です。

AWS SAMを用いてローカルでLambdaを開発する時に独自の環境変数をどう扱うか

はじめに

この記事は、AWS SAMを用いてLambda関数の実装をローカルで行う際に、
ユーザ独自の環境変数をどう扱うかについてハマってしまったことをまとめた記事です。
Lamnda関数を通してTwitterAPIを用いてツイートを送信する処理を実装したのですが、ローカルでの実行時にAPIキーなどを扱う時に苦労しました。
これが正解かどうかは確かではありませんが、ローカルでの検証や実行を行う際のやり方の一つではないかと思います。

godotenvが使えない…?

今回、Lambda関数はGolangを用いて開発をしました。
Golangにおいて環境変数を読み込む時は、godotenvというオープンソースパッケージを用いて.envファイルから環境変数を読み込んでいます。

.env
HOGE_ACCESS_TOKEN=""
HOGE_ACCESS_TOKEN_SECRET=""
main.go
err := godotenv.Load()

しかし、AWS SAMを用いて開発をした場合、この.envファイルを読み込むのには少し癖があるそうです。
というのも、AWS SAMを用いて開発をした場合、SAM側はLambdaを実行している時だけDockerコンテナを立ち上げるため、通常go run main.goコマンドを使ってプログラムを実行するだけで読み込めた.envファイルが読み込めない場合がほとんどです。
※こちらについて、僕なりに.envファイルをDocker Lambdaで読み込む方法を調べましたが、良い方法を見つけることが出来ませんでした?

環境変数を定義するJSONから読み込む

SAMのドキュメントを読んでみると、--env-varsという環境変数を読み込むためのオプションが用意されています。

スクリーンショット 2020-10-27 21.31.12.png

環境変数ファイルとは何かというのをさらに調べたところ、Lambda関数に定義されている環境変数をJSON形式で書くことによって、それらを上書きできるというものらしいです。
Lambda関数の環境変数の定義はAWSのコンソール上からも定義が出来るので、それらをJSONで管理しているものと考えて良いでしょう。

ドキュメントによると、JSONは以下のように定義が出来ます。

env.json
{
  "Your function name": {
    "TABLE_NAME": "hoge",
    "BUCKET_NAME": "fuga"
  }
}

今回私はTwitterのAPIキーをこのenv.jsonに記載しました。
なおこのJSONファイルは、.gitignoreに含めることをオススメします。リポジトリにはenv.sample.json的な名前をつけた空っぽのファイルをpushしましょう。

env.json
{
  "Your function name": {
    "TWITTER_API_KEY": "hogehoge",
    "TWITTER_SECRET_KEY": "fugafuga"
  }
}

template.yamlから定義済み環境変数を読み込む

env.jsonに定義したファイルを、今度はLambda関数に読み込ませます。
SAM templateを用いてLambda関数を生成した場合、template.yamlというファイルが生成されていると思います。

SAM templateについて

こちらのyamlファイルのParameterセクションにAPIキーのフィールドを定義します。

template.yaml
Description: >
  Sample function
Params:
  TwitterAPIKey:
    Type: String
  TwitterSecretKey:
    Type: String

受け取ったAPIキーを、Resourcesセクションの中でこのlambda関数の環境変数として設定をします。

template.yaml
Resources:
  SampleFunction:
    Type: AWS::Serverless::Function 
    Properties:
      Environment:
        Variables:
          TWITTER_API_KEY: !Refs TwitterAPIKey
          TWITTER_SECRET_KEY: !Refs TwitterSecretKey

コードから環境変数を読み込み、実行

読み込まれた環境変数は、os.Getenv()を使ってコード上から呼び出すことが可能です。

main.go
// getCredential TwitterAPIを取得する
func getCredential() *anaconda.TwitterApi {
  return anaconda.NewTwitterApiWithCredentials(
    os.Getenv("TWITTER_ACCESS_TOKEN"),
    os.Getenv("TWITTER_ACCESS_TOKEN_SECRET"),
  )
}

忘れてはならないのが、sam local invokeコマンドを使って実行する際に--env-varsオプションをつけて実行することです。

sam build; sam local invoke SampleFnuction \
    --env-vars env.json

まとめ

godotenvを使って環境変数を読み込む方法を見つけられなかったので、JSONファイルから独自の環境変数を定義して読み込むやり方を採用しましたが、これよりも良い方法が何かしらあると思っています。

実際に、aws-sam-cliのissueにも、dotenvファイルを読み込むオプションを作らないかという提案が上がっているのを見つけました。

feat(init): load .env file at startup of sam local #1355

現状、sam-cliを使って.envファイルをローカルで読み込む方法は無さそうなので、しばらくは今回紹介したやり方で運用をしようかと思います。

参考

AWS SAM
godotenv
aws-sam-cli

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

Go初心者がDatastore・GAEを使用したクラウドネイティブなWebアプリを作成してみた

はじめに

Qiita初投稿です。
IT業界に未経験で転職して半年が経ったので、これまでのGo言語学習の成果をアウトプットする為にWEBアプリを作成してみました。
今記事ではこのWEBアプリを紹介していきます。
(初学者ですので、誤っている点などがありましたら、是非コメントでご指摘ください:bow_tone1:)

簡易4択問題集作成・学習アプリ

1 デモ

2 機能紹介・実装内容

(1)アカウント機能

  • 作成
    formから取得したアカウント情報を元に、Datastore内で発行した主キーでデータを登録しています。 またパスワード再発行機能を考慮して、外部アカウントでログインする時はメールアドレスは登録しないように実装しています。そうする事でDatastore内のメールアドレスは常に一意になっています。
handler.go
/*CreateAccountはuserの構造体を作成して、Datastore(主キーに関しては自動生成)に格納する関数*/
func (a *App) CreateAccount(w http.ResponseWriter, r *http.Request) {
    password := r.FormValue(password)
    user := UserAccount{
        Name:            r.FormValue(userName),
        OauthFlg:        false,
        Mail:            r.FormValue(email),
        ProfileImg:      Environment.ImgUrl + "NoImage.jpg",
        CreateTimeStamp: time.Now(),
        UpdateTimeStamp: time.Now(),
    }

 (省略)

    //パスワードは暗号化する(ハッシュ値)
    hash, err := HashFiled(password)
    if err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, nil)
        a.ReadTemplate(w, PageAccountCreate, showAccountCreate, SendDate)
        return
    }
    user.HashPassword = hash

    //データストア側でユーザIDを発行・主キーにしてデータを登録
    err = a.DB.InsertUserAccount(user)
    if err != nil || SendDate["message"] != "" {
        a.WriteLog(ErrDBProcessing, err)
        a.ReadTemplate(w, PageAccountCreate, showAccountCreate, SendDate)
        return
    }
}
 (省略)
  • ログイン
    formから取得したパスワードとハッシュ値をbcryptoパッケージを使用して比較させています。
hadler.go
/*ValidateLoginDataはメールアドレスを主キーにしてDatastoreからエンティティを取得、パスワードと一致しているかチェックする関数*/
func (a *App) ValidateLoginData(w http.ResponseWriter, r *http.Request) {
    user := UserAccount{
        Mail: r.FormValue(email),
    }
    password := r.FormValue(password)

    (省略)

    //メールアドレスは一意な状態で登録している
    err, user := a.DB.CheckUserLogin(user, password)
    if err != nil {
        a.ReadTemplate(w, PageLogin, showLogin, SendDate)
        return
    }

    //セッションを取得して、データベースから取得したユーザ情報をセッション・メモリに格納
    session, err := GetSession(r)
    if err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, nil)
        a.ReadTemplate(w, PageLogin, showLogin, SendDate)
        return
    }

    CreateSession(w, r, session, user)
(省略)
}
  • 更新
    常にセッションにあるデータをmapに入れ替えているので、そこからid(主キー)を取得してアカウントを更新します。 ちなみに入力チェックはフロント側でもサーバサイド側でも実施しています。
    (作成やログイン時も同じく実施)
handler.go
/*UpdateAccountは入力されたユーザ情報を元にDatastoreのデータを更新する関数*/
func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) {
    user := UserAccount{
        Name:            r.FormValue(userName),
        Mail:            r.FormValue(email),
        UpdateTimeStamp: time.Now(),
    }

    if !NameValidate(user.Name) || !MailValidate(user.Mail) {
        (省略)
    }
    if r.FormValue(password) != "" || PasswordValidate(password) {
        (省略)
    }

    id := SendDate["user"].(map[string]interface{})["id"].(int64)
    err, tmp := a.DB.UpdateUserAccount(id, user)
    if err != nil {
        a.WriteLog(ErrDBProcessing, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
    }

    session, err := GetSession(r)
    if err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    CreateSession(w, r, session, tmp)
(省略)
}
  • 削除
    これも更新と同じく、mapに入れてあるid(主キー)を元に削除しています。
handler.go
/*DeleteAccountはDatastore内に登録されているユーザアカウントを削除する関数*/
func (a *App) DeleteAccount(w http.ResponseWriter, r *http.Request) {
    id := SendDate["user"].(map[string]interface{})["id"].(int64)

    err := a.DB.DeleteUserAccount(id)
    if err != nil {
        a.WriteLog(ErrDBProcessing, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
    }
(省略)
}
  • OAuth
    各アプリの認証ページから私のアプリにリダイレクトした際に、リクエストパラメータで渡される項目をDatastoreに登録しています。
handler.go
/*ExternalAuthenticationFaceBookはfacebookアカウントを取得して、データベースに登録、ログインする関数*/
func (a *App) ExternalAuthenticationFaceBook(w http.ResponseWriter, r *http.Request) {
    user, err := a.Facebook.FetchFacebookAccount(r)
    if err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, nil)
        a.ReadTemplate(w, PageLogin, showLogin, SendDate)
        return
    }

    user, err = a.CheckDBOauthAccount("Facebook", user)
    if err != nil {
        a.WriteLog(ErrDBProcessing, err)
        SetMessage(errorMessage, nil)
        a.ReadTemplate(w, PageLogin, showLogin, SendDate)
        return
    }
(省略)
}
auth.go
/*FetchHttpClientは外部認証に必要なクライアント情報を返す関数*/
func FetchHttpClient(conf *oauth2.Config, r *http.Request) (*http.Client, error) {
    code := r.URL.Query()["code"]
    if code == nil {
        err := errors.New("外部認証エラー")
        return nil, err
    }
    ctx := context.Background()
    tok, err := conf.Exchange(ctx, code[0])
    if err != nil {
        return nil, err
    }

    return conf.Client(ctx, tok), nil
}

/*各func Fetch~はurlから各アプリのユーザアカウント情報を取得して、構造体に詰め替えて返す関数*/
func (f *FacebookClient) FetchFacebookAccount(r *http.Request) (UserAccount, error) {
    client, err := FetchHttpClient(f.Conf, r)
    if err != nil {
        return UserAccount{}, err
    }

(省略)

    res, err := session.Get("/me?fields=id,name,email,picture", nil)
    if err != nil {
        return UserAccount{}, err
    }

    //Datastoreに登録できるよう、ini64型にキャストしておく
    id := res["id"]
    userId, err := strconv.ParseInt(id.(string), 10, 64)
    if err != nil {
        return UserAccount{}, err
    }

(省略)

    user := UserAccount{
        FacebookId:      userId,
        OauthFlg:        true,
        Name:            res["name"].(string),
        ProfileImg:      pictureUrl,
        CreateTimeStamp: time.Now(),
        UpdateTimeStamp: time.Now(),
    }

    return user, nil
}

(2)パスワード再発行機能

  • JWTの発行・検証・メール送信
    JWTについてはこちらの記事が分かりやすく説明してくれています。↓
    JWT について調べた内容をまとめました。
    この技術を使用して、Datastoren内に登録されているメールアドレス宛にJWTがクエリパラメータで設定されているメールを送信します。 JWTの中身はユーザに見られてもいい様にユーザid・作成タイムスタンプ・有効期限しか入れていません。またJWTが改変されている場合はエラー画面に遷移します。 ちなみにメール送信についてもGoで実装しています。
handler.go
/*SendReissueEmailはすでに登録されているメールアドレス充てにパスワード再発行用のメールを送信する関数*/
func (a *App) SendReissueEmail(w http.ResponseWriter, r *http.Request) {
    searchMail := r.FormValue(email)

(省略)

    //メールアドレスを元にアカウントを返す
    id, err := a.DB.SelectUserAccountMail(searchMail)
    if err != nil {
        (省略)
    }

    //検索されたユーザのメールアドレスにメールを送信
    err = gmailSend(searchMail, id)
    if err != nil {
        (省略)
    }

(省略)
}

func (a *App) ShowRecoverPasswordPage(w http.ResponseWriter, r *http.Request) {
    /*トークンの中身
    1 ユーザID
    2 作成タイムスタンプ
    3 有効期限
    */
    tokenString := r.URL.Query().Get("token")
    if tokenString == "" {
        SetMessage(errorTokenMessage, nil)
        a.ReadTemplate(w, PageAccountCreate, showAccountCreate, SendDate)
        return
    }

    claims := jwt.MapClaims{}
    _, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
        return []byte(Environment.JwtSing), nil
    })

    if err != nil {
        (省略)
    }

    //セッションにユーザIDは入れない
    SendDate["id"] = claims["id"]
(省略)
}
mail.go
/*bodyはメールの内容を作成する関数*/
func (m mail) body() string {
    return "To: " + m.to + "\r\n" +
        "Subject: " + m.sub + "\r\n\r\n" +
        m.msg + "\r\n"
}

/*gmailSendは引数のメールアドレス充てにGmailメールを送信する関数*/
func gmailSend(send string, id int64) error {
    token, err := CreateToken(id)
    if err != nil {
        return err
    }

    m := mail{
        from:     Environment.MailAddress,
        username: Environment.MailName,
        password: Environment.MailPassWord,
        to:       send,
        sub:      "Workbook | パスワードリセット",
    }
    m.msg = "workbookのログインパスワードの再設定を完了させるには、" + "\r\n" +
        "下記のURLにアクセスし、新しいパスワードを設定してください。" + "\r\n" +
        "https://workbook-292312.an.r.appspot.com/login/recover-password/page?token=" + token + "\r\n" +
        "*パスワード再設定URLの有効期間は発行から一時間です。有効期間を超えると上記URLから再設定が行えなくなりますので、ご注意ください。" + "\r\n" +
        "―――――――――――――――――――――――――――――――――――" + "\r\n" +
        "このメッセージはworkbookよりログインパスワード再設定手続きを" + "\r\n" +
        "行われたお客様に送信されています。" + "\r\n" +
        "お心当たりのない場合は、他の方がメールアドレスを間違えて登録された" + "\r\n" +
        "可能性がございます。" + "\r\n" +
        "その際には、誠にお手数ですが" + Environment.MailAddress + "までご連絡ください。" + "\r\n" +
        "―――――――――――――――――――――――――――――――――――"

    smtpSvr := "smtp.gmail.com:587"
    auth := smtp.PlainAuth("", m.username, m.password, "smtp.gmail.com")
    if err := smtp.SendMail(smtpSvr, auth, m.from, []string{m.to}, []byte(m.body())); err != nil {
        return err
    }
    return nil
}
auth.go
/*CreateTokenはパスワード再発行用にユーザIDを記載したJWTを発行する関数*/
func CreateToken(id int64) (string, error) {
    token := jwt.New(jwt.SigningMethodHS256)

    claims := token.Claims.(jwt.MapClaims)
    claims["id"] = strconv.FormatInt(id, 10)
    claims["iat"] = time.Now()
    claims["exp"] = time.Now().Add(time.Hour * 1).Unix()

    tokenString, err := token.SignedString([]byte(Environment.JwtSing))

    return tokenString, err
}

(3)4択問題集機能

  • 作成
    最大20問の問題を作成する事が出来ます。設問については構造体のスライスでデータを保持しています。 またformから画像データも同時に取得する際は、ParseMultipartFormメソッドを使用する必要があります。
entity.go
//問題集(ユーザーID+タイトル+設問の配列)
type WorkbookContent struct {
    UserId   int64
    BookId   int64
    ShareId  int64
    Author   string
    Category string
    Title    string
    Image    string
    Contents        []Content
    CreateTimeStamp time.Time
    UpdateTimeStamp time.Time
}

//設問
type Content struct {
    ProblemNumber    string
    ProblemStatement string
    Choice1          string
    Choice2          string
    Choice3          string
    Choice4          string
    Answer           string
    Explanation      string
}
handler.go
/*CreateWorkBookはフォームから取得した問題集の情報をbookIdを元にDatastoreに登録する関数*/
func (a *App) CreateWorkBook(w http.ResponseWriter, r *http.Request) {
    err := r.ParseMultipartForm(32 << 20)
    if err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    mf := r.MultipartForm.Value
    if len(mf) == 0 {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    var UpFileName string
    file, fileHeader, err := r.FormFile(image)
    if err != nil {
        a.WriteLog(ErrLog, err)
        UpFileName = "NoImage.jpg"
    } else {
        UpFileName = fileHeader.Filename
    }

    workbook := WorkbookContent{
        UserId:          SendDate["user"].(map[string]interface{})["id"].(int64),
        Author:          SendDate["user"].(map[string]interface{})["name"].(string),
        Image:           Environment.ImgUrl + UpFileName,
        CreateTimeStamp: time.Now(),
        UpdateTimeStamp: time.Now(),
    }
    workbook.Title = mf["title"][0]
    workbook.Category = mf["category"][0]

(省略)

    workbook.Contents = make([]Content, 0)
    for i := 1; i <= total; i++ {
        s := strconv.Itoa(i)
        content := Content{
            ProblemNumber:    mf["problem"+s][0],
            ProblemStatement: mf["statement"+s][0],
            Choice1:          mf["choices"+s+"-1"][0],
            Choice2:          mf["choices"+s+"-2"][0],
            Choice3:          mf["choices"+s+"-3"][0],
            Choice4:          mf["choices"+s+"-4"][0],
            Answer:           mf["answer"+s][0],
            Explanation:      mf["commentary"+s][0],
        }

        workbook.Contents = append(workbook.Contents, content)
    }

(省略)

    err = a.DB.InsertWorkbook(workbook)
    if err != nil {
        a.WriteLog(ErrDBProcessing, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }
(省略)
}
  • 削除
    hidden項目で保持しているbookIdを元にDatastoreから削除する事が可能です。 またJavaScriptで各ボタンクリック時にhidden項目のnameが設定されるようにしているので、FormValue()に渡す引数は常に同じ文字列で取得できます。
handler.go
/*DeleteWorkBookはformから取得したbookIdに該当するデータを削除する関数*/
func (a *App) DeleteWorkBook(w http.ResponseWriter, r *http.Request) {
    id := r.FormValue(bookId)
    if id == "" {
        SetMessage("削除が実行出来ませんでした。", "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    bookId, err := strconv.ParseInt(id, 10, 64)
    if err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    userId := SendDate["user"].(map[string]interface{})["id"].(int64)

    err = a.DB.DeleteWorkBook(bookId, userId)
    if err != nil {
        a.WriteLog(ErrSTProcessing, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }
(省略)
}
  • 学習
    学習画面については設問ごとにページ遷移はさせずに、JavaScriptで表示・非表示の処理を実装しています。

  • 回答
    解答処理についてはサーバサイド側で実装しており、選択したチェックボックスと回答の内容をmapに入れてフロント側に渡しています。

handler.go
/*CheckAnswerWorkBookはformから取得した値を元に回答を実施、結果をmapで作成する関数*/
func (a *App) CheckAnswerWorkBook(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseForm(); err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    questionTotal := r.FormValue("question-total")
    total, err := strconv.Atoi(questionTotal)
    if err != nil {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    //問題番号だけ抽出(チェックされた値さえ分かれば、回答が出来る為)
    //Go1.12+からmapのキーが自動的にソートされる仕組みを利用
    answer := make(map[string]string)
    choice := make(map[string]string)
    for k, v := range r.Form {
        if v[0] == "on" {
            checked := strings.Replace(k, "check", "", 1)[0:1]
            answer[checked] = k
            choice[checked] = k
        }
    }

    //使用者が選択せずに無理やりsubmitしたとき
    if total != len(choice) {
        a.WriteLog(ErrLog, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    for i := 1; i <= total; i++ {
        s := strconv.Itoa(i)
        //不正解だった場合マップから値を削除
        if answer[s][7:] != r.FormValue("Answer"+s) {
            delete(answer, s)
        }
    }

    bookId := r.FormValue(bookId)

    err, book := a.DB.SelectWorkbook(bookId)
    if err != nil {
        a.WriteLog(ErrDBProcessing, err)
        SetMessage(errorMessage, "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }
    kind := r.FormValue("kind")

    SendDate["learningBook"] = book
    SendDate["checkBook"] = answer
    SendDate["choiceBook"] = choice
    SendDate["kind"] = kind
(省略)
}
  • 共有
    他のユーザーが作成した問題を学習する事が出来る機能です。 仕組みとしては、workbook(カインド)に登録されているデータを別カインドに登録しているだけです。 また作者本人であれば削除が可能になる処理を実装しています。 (仕組みは単純です。)
handler.go
/*UploadWorkBookはDatastore内のshare_book(カインド)に、フォームから取得した問題集(bookIdを元に検索をかけた後に)を登録する関数*/
func (a *App) UploadWorkBook(w http.ResponseWriter, r *http.Request) {
    bookId := r.FormValue(bookId)
    if bookId == "" {
        SetMessage("共有が実行出来ませんでした。", "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }

    err := a.DB.InsertShareWorkbook(bookId)
    if err != nil {
        a.WriteLog(ErrSTProcessing, err)
        SetMessage("すでにアップロード済みです", "block")
        a.ReadTemplate(w, PageHome, showHome, SendDate)
        return
    }
(省略)
}
database.go
/*InsertShareWorkbookはbookIDを元に取得した情報を、share_bookカインドに登録する関数*/
func (c *Client) InsertShareWorkbook(bookId string) error {
    ctx := context.Background()

    err, book := c.SelectWorkbook(bookId)
    if err != nil {
        return err
    }

    var workbook api.WorkbookContent
    query := datastore.NewQuery("share_workbook").Filter("BookId =", book.BookId)
    it := c.DataStore.Run(ctx, query)
    _, err = it.Next(&workbook)
    if err == nil {
        return errors.New("すでにアップロード済みです。")
    }

    parentKey := datastore.IDKey("user_account", book.UserId, nil)
    childKey := datastore.IncompleteKey("share_workbook", parentKey)
    childKey.ID = book.BookId

    _, err = c.DataStore.Put(ctx, childKey, &book)
    if err != nil {
        return err
    }
    return nil
}
workbook_share.html
<div class="article-cta workbook_learning_start">
  {{if checkOwner $book.UserId}}
    <a href="#" class="btn btn-danger" data-toggle="modal"data-target="#modal-danger">削除</a>
  {{end}}
    <a href="#" class="btn btn-primary" data-toggle="modal" data-target="#modal-default">開始</a>
  <input type="hidden" class="book-id" name="" value="{{$book.BookId}}">
</div>
data.go
FuncMap = template.FuncMap{
        //共有ページで、作者なら削除可能ボタン表示にするために使用
        "checkOwner": func(checkId interface{}) bool {
            userId := SendDate["user"].(map[string]interface{})["id"].(int64)
            checkUserId := checkId.(int64)
            if checkUserId == userId {
                return true
            }
            return false
        },
    }

3 選定技術

(1)Go

元々Javaが出来たので、同じような開発が可能なGoを次に学習する言語として選びました。
開発初期は構造体・インターフェースの使い方が理解できておらず、下記のような実装をしてましたが、開発終盤ではGoらしいコードが書けたと思っています。

database.go(初期)
//NewClient はDatastoreのクライアントを生成する関数
func NewClient(ctx context.Context) (*datastore.Client, bool) {
    var client *datastore.Client
    client, err := datastore.NewClient(ctx, project_id)
    if err != nil {
        return nil, false
    }
    return client, true
}

//CheckUserLogin はメールアドレスとパスワードを比較して、booleanとユーザアカウント情報を返す関数
func CheckUserLogin(user UserAccount, password string) (bool, UserAccount) {
    ctx := context.Background()
    client, flg := NewClient(ctx)
    if flg == false {
        return false, user
    }
    defer client.Close()
(省略)
}
datbase.go(現在)
/*NewClientはDatastoreのクライアントを生成する関数*/
func NewClient(ctx context.Context) (*Client, error) {
    client, err := datastore.NewClient(ctx, api.Environment.ProjectId)
    if err != nil {
        return nil, err
    }
    return &Client{
        DataStore: client,
    }, nil
}

/*CheckUserLoginはメールアドレスを元にクエリをかけて、エラーとパスワードが一致した際のユーザアカウントを返す関数*/
func (c *Client) CheckUserLogin(user api.UserAccount, password string) (error, api.UserAccount) {
    ctx := context.Background()
(省略)
}

DB,ST,LDはインターフェースで、DatastoreやCloudStorage関係に必要なメソッドを実装しています。
またhandler.goの中身はほとんどApp(構造体)のメソッドになります。

handler.go(現在)
/*NewAppはDB,Storage,Logging,外部アカウント接続情報を一つにまとめる関数*/
func NewApp(d Repository, s Storage, l Logging, google GoogleClient, facebook FacebookClient, github GithubClient) *App {
    return &App{
        DB:       d,
        ST:       s,
        LD:       l,
        Google:   google,
        Facebook: facebook,
        Github:   github,
    }
}
main.go(現在)
/*runは各クライアントを作成して、登録されたハンドラ関数を元にサーバを起動する関数*/
func run() error {
    ctx := context.Background()

    d, err := database.NewClient(ctx)
    if err != nil {
        return err
    }

    s, err := api.NewStorageClient(ctx)
    if err != nil {
        return err
    }

    l, err := api.NewLoggingClient(ctx)
    if err != nil {
        return err
    }

    google := api.NewGoogleClient()
    facebook := api.NewFacebookClient()
    github := api.NewGithubClient()

    app := api.NewApp(d, s, l, google, facebook, github)
    router := api.Route(app)
(省略)
}
entity.go(現在)
//DBのリポジトリ
type Repository interface {
    CheckUserLogin(user UserAccount, password string) (error, UserAccount)
    InsertUserAccount(user UserAccount) error
    (省略)
}

//ストレージとロギングのリポジトリ
type Client struct {
    CloudStorage *storage.Client
    Logging      *logging.Client
}

//ロギングの接続情報
type Logging interface {
    WriteStackDriverLog(appErr *ApplicationError)
}

//ストレージの接続情報
type Storage interface {
    UploadImg(file multipart.File, fileHeader *multipart.FileHeader) error
}

//アプリケーション情報
type App struct {
    DB       Repository
    ST       Storage
    LD       Logging
    Google   GoogleClient
    Facebook FacebookClient
    Github   GithubClient
}

(2)Datastore

これについてはGCPが提供していた、ストレージオプションフローに従った為です。
またNoSQLにも挑戦してみたかったという意図もありました。
(現在この画像は公式サイトには無い・・・よね?)
fcfa36a8d7e7ea035db3343dc7c65de5.png
実際に使ってみると、部分一致検索(LIKE)が提供されていなかったりと不便なところはありますが、単純なデータ構造のみを使用する開発においてはDatastoreの方がSQLより便利な気がします。
(次はCloudSQLを選ぶが・・・)

GCPで提供されているデータベースを詳しく知りたい方はこちらを参照お願いします。↓
Google Cloud データベース

(3)CloudStorage

これもGCPのベストプラクティスに従っています。簡単にファイルをアップロードする事が出来て、とても便利なので今後も使用していきたいと思っています。

(4)GoogleAppEngine

これもGCPのベストプラクティスに従っている為です。
(GCPを選ぶ理由はGoogleが好きだからですねʕ◔ϖ◔ʔ。読者の方はちゃんと考えてクラウド環境を選びましょう)
CloudFunctionsではアプリケーション全体はデプロイできないので、GAE SEにデプロイしています。
GAEの特徴としては、バージョンごとにリクエストを分割できる機能やHTTPSが無料で使える点です。
(機能はもっとあるよ!)
32245.max-800x800.png

またアプリ実行環境選定基準についてはこちらを参照ください↓
Google Cloud でのアプリのホスティング
GCEとGAE、どっち使えばいいの?という人のためのGCP運用診断
GCP - GAE, GCE, GKEの決定木

(5)CloudBuild・CircleCI

GCPでの開発だったので、メインはCloudBuildになります。CircleCIでもテスト・デプロイが出来るよう.circleci/config.ymlは作成しています。今回は設定ファイルが簡単に作成できるCloudBuildを選びましたが、今後はCircleCIも活用していきたいです。
またこちらの記事がCloudBuildのメリットを分かりやすく説明しているのでお勧めです。↓
GCPを使ってるならCloud Buildがいいんじゃない?

(6)Docker

私はwindowsで開発しているのですが、windows以外でも実行できるようにコンテナ化しておきました。
Dockerfileやdocker-compose.ymlだけで、環境に依存せずにアプリを実行するできるのはとても便利ですね。
(今の現場でも取り入れてほしいなあ・・・)

4 頑張った点

  • 構造体・インターフェースの活用
    これについては技術選定でも記載したので詳しくは書きませんが、Goらしいコードが書けたと思っています。

  • CI/CDパイプラインの構築
    CI/CDとは何?という状況からの開発だった為、構築には時間が掛かりましたが何とか自動化する事に成功しました。テストコードについてはまだまだ完成とは程遠いですが、デプロイが自動になっただけでも恩恵はとても大きかったです。
    次はテストコードももっと記述して、CI/CDを最大限に活用できるように開発していきたいです。
    (今の現場でも取り入れてほしいなあ・・・)

感想

Go、GCP、Docker、CI/CDって何?からの状態で何とか完成させる事が出来き、何かを作り上げる経験を出来たのはとても良かったです。今後はプログラミングの基礎力を上げる為にアルゴリズムの学習や実際にサービスとして提供できるレベルの開発にも挑戦していきたいです。

参考にさせて頂いた記事・リファレンス

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

Control files to build in Golang project

Background

We are developing a project that needs to run on Windows and Linux. The project is written in Golang. There are a lot of codes that can be shared between these OS, so we decided to make it in the same project. By this way we can speed up the development process very much.

In the project, there are some code files that run on Windows only, some run on Linux only, and some run on both Windows and Linux. And we want to separating them on build. It means:

  • The binary for Linux will contain common code + code for Linux
  • The binary for Windows will contain common code + code for Windows

Benefit

By separating files on build, I can see that we have some benefits:

  • We don't have to check runtime.GOOS to determine which code will run and which will not. We can write less code to do the same thing.
  • Changes for Linux environment will not affect the behaviors on Windows and vice versa. So it help us reduce time for testing.
  • The test code will be built and run on the specified OS only, so we get a more accurate code coverage percentage.

How can we do it

Firstly we need to divide the code for Linux and for Windows into different files. After that we can use one of the following ways.

Name the file

We can tell go what OS and what architecture we want to build a file by naming it. The followings are examples about file names and build behaviors:

  • hello_windows.go, hello_windows_test.go: will only be built and tested on Windows
  • hello_linux.go, hello_linux_test.go: will only be built and tested on Linux
  • hello_linux_adm64.go, hello_linux_adm64_test.go: will only be built and tested on Linux 64 bit

The file name patterns are:

  • *_GOOS.go, *_GOOS_test.go
  • *_GOOS_GOARCH.go, *_GOOS_GOARCH_test.go

We can do this way if the build condition is only about OS and architecture.

Use // +build command

This way gives us more control on the environment. We can specify multiple OS and architectures, compiler, go version, -tags

// file name: hello.go
// +build linux windows
// +build amd64

package hello

The comment // +build above will tell go to build the file on Windows 64bit or Linux 64 bit. So when we build the project in freebsd, the package hello will be excluded from the files to be built.

If we have a test for this package, it will be something like this:

// file name: hello_test.go
// +build linux windows
// +build amd64

package hello

The comment // +build above will tell go to exclude this file from test on environments that are not Linux 64bit or Windows 64bit for us. We don't have to check the OS then skip the test.

For complex usage of // +build, please check golang documentation here: https://golang.org/cmd/go/#hdr-Build_constraints

Reference

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

Golangのnet/httpでCORSを全許可するときにつけるヘッダー

func handler(w http.ResponseWriter, r *http.Request) {
   w.Header().Set("Access-Control-Allow-Headers", "*")
   w.Header().Set("Access-Control-Allow-Origin", "*")
   w.Header().Set( "Access-Control-Allow-Methods","GET, POST, PUT, DELETE, OPTIONS" )
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む