- 投稿日:2020-10-27T22:07:41+09:00
AWS SAMを用いてローカルでLambdaを開発する時に独自の環境変数をどう扱うか
はじめに
この記事は、AWS SAMを用いてLambda関数の実装をローカルで行う際に、
ユーザ独自の環境変数をどう扱うかについてハマってしまったことをまとめた記事です。
Lamnda関数を通してTwitterAPIを用いてツイートを送信する処理を実装したのですが、ローカルでの実行時にAPIキーなどを扱う時に苦労しました。
これが正解かどうかは確かではありませんが、ローカルでの検証や実行を行う際のやり方の一つではないかと思います。godotenvが使えない…?
今回、Lambda関数はGolangを用いて開発をしました。
Golangにおいて環境変数を読み込む時は、godotenvというオープンソースパッケージを用いて.envファイルから環境変数を読み込んでいます。.envHOGE_ACCESS_TOKEN="" HOGE_ACCESS_TOKEN_SECRET=""main.goerr := godotenv.Load()しかし、AWS SAMを用いて開発をした場合、この
.env
ファイルを読み込むのには少し癖があるそうです。
というのも、AWS SAMを用いて開発をした場合、SAM側はLambdaを実行している時だけDockerコンテナを立ち上げるため、通常go run main.go
コマンドを使ってプログラムを実行するだけで読み込めた.env
ファイルが読み込めない場合がほとんどです。
※こちらについて、僕なりに.env
ファイルをDocker Lambdaで読み込む方法を調べましたが、良い方法を見つけることが出来ませんでした?環境変数を定義するJSONから読み込む
SAMのドキュメントを読んでみると、
--env-vars
という環境変数を読み込むためのオプションが用意されています。環境変数ファイルとは何かというのをさらに調べたところ、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
というファイルが生成されていると思います。こちらのyamlファイルの
Parameter
セクションにAPIキーのフィールドを定義します。template.yamlDescription: > Sample function Params: TwitterAPIKey: Type: String TwitterSecretKey: Type: String受け取ったAPIキーを、
Resources
セクションの中でこのlambda関数の環境変数として設定をします。template.yamlResources: 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
ファイルをローカルで読み込む方法は無さそうなので、しばらくは今回紹介したやり方で運用をしようかと思います。参考
- 投稿日:2020-10-27T09:44:22+09:00
Go初心者がDatastore・GAEを使用したクラウドネイティブなWebアプリを作成してみた
はじめに
Qiita初投稿です。
IT業界に未経験で転職して半年が経ったので、これまでのGo言語学習の成果をアウトプットする為にWEBアプリを作成してみました。
今記事ではこのWEBアプリを紹介していきます。
(初学者ですので、誤っている点などがありましたら、是非コメントでご指摘ください)簡易4択問題集作成・学習アプリ
1 デモ
WEBアプリのリポジトリ
https://github.com/Gompei/WorkBookApp2 機能紹介・実装内容
(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 } } (省略)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) (省略) }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) } (省略) }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 } (省略) }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.goFuncMap = 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にも挑戦してみたかったという意図もありました。
(現在この画像は公式サイトには無い・・・よね?)
実際に使ってみると、部分一致検索(LIKE)が提供されていなかったりと不便なところはありますが、単純なデータ構造のみを使用する開発においてはDatastoreの方がSQLより便利な気がします。
(次はCloudSQLを選ぶが・・・)GCPで提供されているデータベースを詳しく知りたい方はこちらを参照お願いします。↓
Google Cloud データベース(3)CloudStorage
これもGCPのベストプラクティスに従っています。簡単にファイルをアップロードする事が出来て、とても便利なので今後も使用していきたいと思っています。
(4)GoogleAppEngine
これもGCPのベストプラクティスに従っている為です。
(GCPを選ぶ理由はGoogleが好きだからですねʕ◔ϖ◔ʔ。読者の方はちゃんと考えてクラウド環境を選びましょう)
CloudFunctionsではアプリケーション全体はデプロイできないので、GAE SEにデプロイしています。
GAEの特徴としては、バージョンごとにリクエストを分割できる機能やHTTPSが無料で使える点です。
(機能はもっとあるよ!)
またアプリ実行環境選定基準についてはこちらを参照ください↓
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って何?からの状態で何とか完成させる事が出来き、何かを作り上げる経験を出来たのはとても良かったです。今後はプログラミングの基礎力を上げる為にアルゴリズムの学習や実際にサービスとして提供できるレベルの開発にも挑戦していきたいです。
参考にさせて頂いた記事・リファレンス
【Go・開発環境構築】
Golandをつかいましょう 2019冬【Go・文法】
【Go】基本文法①(基礎)【Go・パッケージ管理】
Goにはディレクトリ構成のスタンダードがあるらしい。
【Go golang】自作パッケージのimport【Go・セッション管理】
Go 言語で gorilla/mux, gorilla/context, gorilla/sessions を使ったユーザーログイン機能のサンプル【Go・外部アカウント認証】
Go言語でGoogle,Twitter,FacebookのOauth認証をしてメールアドレスを取得するまで【Go・JWT】
Go言語で理解するJWT認証 実装ハンズオン【Datastore】
Datastore モードの Firestore に関するドキュメント
Cloud Datastoreのクエリでがんばるハナシ【CloudStorage】
Go言語(golang)でGoogle Cloud Storageからファイルを取得する
Go言語(golang)でGoogle Cloud Storageへファイルをアップロードする【Cloud Build】
GCPを使ってるならCloud Buildがいいんじゃない?【Docker】
DockerでGoの開発環境を構築する
Docker上のGo製Webアプリケーションをリモートデバッグする【Qiita記事執筆参考】
【Python+Flask】WebAPIを使った簡易書籍管理アプリ【Vue.js、Elasticsearch】
- 投稿日:2020-10-27T07:59:27+09:00
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 Windowshello_linux.go
,hello_linux_test.go
: will only be built and tested on Linuxhello_linux_adm64.go
,hello_linux_adm64_test.go
: will only be built and tested on Linux 64 bitThe 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
commandThis 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 helloThe comment
// +build
above will tellgo
to build the file on Windows 64bit or Linux 64 bit. So when we build the project infreebsd
, the packagehello
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 helloThe comment
// +build
above will tellgo
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_constraintsReference
- 投稿日:2020-10-27T02:41:39+09:00
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" )