- 投稿日:2019-05-06T23:49:51+09:00
入力項目の意味を調べる
前回書いた以下`hello.go'の中身、特に前半に書いてある以下3つの意味をちゃんと知っておきたい。内容を読めばなんとなく分かるけど、所詮なんとなくなので。
package main
import "fmt"
func main()
package main import "fmt" func main() { fmt.Println("hi.this is small step,but will be big one for me.") }文言を検索すると色々と解説しているサイトが出てくるが残念ながら自分にはちゃんと理解出来なかった。今日は遠方から戻ってきて疲れたのでまた明日調べ直す。そんな訳でお休みなさい。
- 投稿日:2019-05-06T22:32:28+09:00
Go言語で認証機能を作ろう!
■ はじめに
Go 言語で、API を作る。
職場で、とある API を利用したことがある。
そのサービスは、以下のような仕組みになっていた。
- ユーザー登録
- ユーザー認証
- 認証後、token を発行
- token を使って、サービスを利用
上記の処理を Go 言語で実装する。
必要な機能は、以下の通りだ。
<必要な機能>
1. Signup
2. login
3. 何らかのサービス■ パッケージのインストール
今回使う Go のパッケージ。
go get -u github.com/gorilla/mux go get github.com/dgrijalva/jwt-go go get github.com/lib/pq go get -u github.com/davecgh/go-spew/spew go get -u golang.org/x/crypto/bcrypt go get github.com/subosito/gotenv■ エンドポイントの作成
エンドポイントの作成に、gorilla/muxを使う。
main.gopackage main import "github.com/gorilla/mux" func main() { // Django の urls.py っぽい router := mux.NewRouter() // endpoint(singup/loginは未実装なので、エラーになる) router.HandleFunc("/singup", signup).Methods("POST") router.HandleFunc("/login", login).Methods("POST") // 何らかの service }signup と login のエンドポイントを実装した。
ユーザーがリクエストを送ると、リクエストをさばく存在が必要になる。
gorilla router がリクエストをさばく役割を担ってくれる。
例えば、localhost:8000/login を POST 形式で送ると gorilla router がリクエストをさばいてくれる。
Django の urls.py みたいな存在だ。
■ リクエスト処理関数の実装
Django では、views.py にリクエストの処理を書くが、Go 言語は不慣れなので、ひとまず、main.go に記述する。
main.gopackage main import ( "fmt" "log" "net/http" "github.com/gorilla/mux" ) func signup(w http.ResponseWriter, r *http.Request) { fmt.Println("signup 関数実行") } func login(w http.ResponseWriter, r *http.Request) { fmt.Println("login 関数実行") } func main() { // urls.py router := mux.NewRouter() // endpoint router.HandleFunc("/singup", signup).Methods("POST") router.HandleFunc("/login", login).Methods("POST") // service はあとで記述する // console に出力する log.Println("サーバー起動 : 8000 port で受信") // log.Fatal は、異常を検知すると処理の実行を止めてくれる log.Fatal(http.ListenAndServe(":8000", router)) }関数に使われている引数は、定型文なので、このまま覚えて欲しい。
参考: Introducing the net/http package (an interlude)
main.gofunc signup(w http.ResponseWriter, r *http.Request) {あと、起動確認をするため、Restlet Client を Chrome にインストールしよう。
コンソール上で、以下のコマンドを実行する。
# サーバー起動 $ go run main.go 2019/05/05 18:54:41 サーバー起動 : 8000 port で受信URL を localhost:8000/signup とし、POST 状態で、Send ボタンを押下すると、
コンソールに結果が表示される。$ go run main.go 2019/05/05 18:56:22 サーバー起動 : 8000 port で受信 signup 関数実行
■ Model を作る
Django だと、models.py に Model クラス(データベースの元になるもの) を作成する。
Go 言語では、struct で同じようなことを行う。
main.gotype User struct { // 大文字だと Public 扱い ID int `json:"id"` Email string `json:"email"` Password string `json:"password"` } type JWT struct { Token string `json:"token"` } type Error struct { Message string `json:"message"` } func signup(w http.ResponseWriter, r *http.Request) { w.Write([]byte("successfully called signup")) } func login(w http.ResponseWriter, r *http.Request) { w.Write([]byte("successfully called login")) }User, JWT, Error モデルを作成した。
まず前提として、API とのデータのやり取りは、JSON形式で行う。
json:"id"
は、json と struct のデータ構造をパースするために指定している。
参考, 公式文章JWTは、ユーザー認証後に Token を発行するので、その Token を格納する。
Error は、エラーメッセージ格納用だ。
■ データベースの導入
データベースには、postgreSQL を利用する。
Django でよく使うので。今回は、一部無料で使える elephantsqlを利用する。
上記サイトから Sing up して、Instance を作成する。
* 筆者は無料枠を使ってます! 有料もあるので、気をつけてください。golang-api という Instance を作成した。
作成に成功すると以下のような画面に入ることができる。画像に表示されている URL はあとで使う。
サブタイトルに BROWSER があり、ここから SQL を発行できる。
# データベースを作成 create table users ( id serial primary key, email text not null unique, password text not null ); # テストデータ insert into users (email, password) values ('first@sample.co.jp', 'golang'); # 確認 select * from users;GolangのDBの操作で、 go-sqlite3 を使った方法を勉強したので、こっちでも良かったが、URL を使って、データベースの登録ができるので、今回はこのサービスを使う。
■ DB 接続
以下は必須の作業ではないが、個人情報格納用のフォルダとファイルを作成する。
mkdir tool touch tool/params.goparams.gopackage tool type Info struct { dburl string } func (u Info) GetDBUrl() string { // elephantSQL の Detail に表示されている URL を記述 return "postgres:hogehoge" }続いて、elephantsqlのサイトに、Go による DB 接続のサンプルコードがあるので、これを参考に DB に接続する。
main.gopackage main import ( "database/sql" # データベース操作に必要なパッケージ "log" "net/http" "github.com/gorilla/mux" "github.com/lib/pq" # database とコードの仲介役(多分、psycopg2のようなもの) "github.com/hoge/goAuthentication2/tool" # 知られたくないデータ(params.go)のパッケージ ) // dbインスタンス格納用 var db *sql.DB func main() { // parmas.go から DB の URL を取得 i := tool.Info{} // Convert // https://github.com/lib/pq/blob/master/url.go // "postgres://bob:secret@1.2.3.4:5432/mydb?sslmode=verify-full" // -> "user=bob password=secret host=1.2.3.4 port=5432 dbname=mydb sslmode=verify-full" pgUrl, err := pq.ParseURL(i.GetDBUrl()) // 戻り値に err を返してくるので、チェック if err != nil { // エラーの場合、処理を停止する log.Fatal() } // DB 接続 db, err = sql.Open("postgres", pgUrl) if err != nil { log.Fatal(err) } // DB 疎通確認 err = db.Ping() if err != nil { log.Fatal(err) } // urls.py router := mux.NewRouter() ... }一応接続できるようになっているはずだが、データ登録する処理を実装してないので、動作確認はできない。
ひとまず、コードが壊れていないことを確認する。
go run main.go 2019/05/06 17:48:29 サーバー起動 : 8000 port で受信Restlet Client から localhost:8000/signup を POST 形式で実行する。
実行後、「successfully called signup」が表示されたので、コードは問題なさそう。
<参考>
pq■ SignUp の実装1
SignUp を実装する。
SignUp は、ユーザーを新規に登録する機能だ。
ちなみに、SignIn は、既存ユーザーを認証する機能らしい。
main.gopackage main import ( "database/sql" "encoding/json" "fmt" "log" "net/http" "github.com/davecgh/go-spew/spew" "github.com/gorilla/mux" "github.com/lib/pq" "github.com/hoge/goAuthentication2/tool" ) // レスポンスにエラーを突っ込んで、返却するメソッド func errorInResponse(w http.ResponseWriter, status int, error Error) { w.WriteHeader(status) // 400 とか 500 などの HTTP status コードが入る json.NewEncoder(w).Encode(error) return } func signup(w http.ResponseWriter, r *http.Request) { var user User var error Error // r.body に何が帰ってくるか確認 fmt.Println(r.Body) // https://golang.org/pkg/encoding/json/#NewDecoder json.NewDecoder(r.Body).Decode(&user) if user.Email == "" { error.Message = "Email は必須です。" errorInResponse(w, http.StatusBadRequest, error) return } if user.Password == "" { error.Message = "パスワードは必須です。" errorInResponse(w, http.StatusBadRequest, error) return } // user に何が格納されているのか fmt.Println(user) // dump も出せる fmt.Println("---------------------") spew.Dump(user) }
go run main.go
でサーバを起動し、Restlet Client で、signup をしてみる。json{ "email": "test1@example.co.jp", "password": "golang" }とりあえず、エラーになっていない。
spew.Dump(user)の結果は、コンソール上で確認できる。
$ go run main.go 2019/05/06 18:07:35 サーバー起動 : 8000 port で受信 &{0xc00016e000 <nil> <nil> false true {0 0} false false false 0x124e4e0} {0 test1@example.co.jp golang} --------------------- (main.User) { ID: (int) 0, Email: (string) (len=19) "test1@example.co.jp", Password: (string) (len=6) "golang" }データが送られてきていることがわかる。
尚、ユーザー情報を DB に登録する処理を書いていないので、DB には反映されていない。
Json.NewEncoder, json.NewDecoder で、エンコード(構造体から文字列)、デコード(文字列から構造体)の処理を行なっている。
そして、errorInResponse 関数で、エラーハンドリングを行なっている。
■ SignUp の実装2
Sign Up の実装の続きだ。
具体的には、パスワードの暗号化とデータベースの登録処理を実装する。
main.gopackage main import ( "database/sql" "encoding/json" "fmt" "log" "net/http" "golang.org/x/crypto/bcrypt" #データの暗号化 "github.com/gorilla/mux" "github.com/lib/pq" "github.com/hoge/goAuthentication2/tool" ) // JSON 形式で結果を返却 // data interface{} とすると、どのような変数の型でも引数として受け取ることができる func responseByJSON(w http.ResponseWriter, data interface{}) { json.NewEncoder(w).Encode(data) return } func signup(w http.ResponseWriter, r *http.Request) { var user User var error Error // r.body に何が帰ってくるか確認 fmt.Println(r.Body) // https://golang.org/pkg/encoding/json/#NewDecoder json.NewDecoder(r.Body).Decode(&user) if user.Email == "" { error.Message = "Email は必須です。" errorInResponse(w, http.StatusBadRequest, error) return } if user.Password == "" { error.Message = "パスワードは必須です。" errorInResponse(w, http.StatusBadRequest, error) return } // user に何が格納されているのか // fmt.Println(user) // dump も出せる fmt.Println("---------------------") // spew.Dump(user) // パスワードのハッシュを生成 // https://godoc.org/golang.org/x/crypto/bcrypt#GenerateFromPassword hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10) if err != nil { log.Fatal(err) } fmt.Println("パスワード: ", user.Password) fmt.Println("ハッシュ化されたパスワード", hash) user.Password = string(hash) fmt.Println("コンバート後のパスワード: ", user.Password) sql_query := "INSERT INTO USERS(EMAIL, PASSWORD) VALUES($1, $2) RETURNING ID;" // query 発行 // Scan で、Query 結果を変数に格納 err = db.QueryRow(sql_query, user.Email, user.Password).Scan(&user.ID) if err != nil { error.Message = "サーバーエラー" errorInResponse(w, http.StatusInternalServerError, error) return } // DB に登録できたらパスワードをからにしておく user.Password = "" w.Header().Set("Content-Type", "application/json") // JSON 形式で結果を返却 responseByJSON(w, user) }
go run main.go
で、サーバを起動する。そして、Restlet Client で、動作確認を行う。
ElephantSQl も確認する。
コンソールも確認する。
--------------------- パスワード: golang ハッシュ化されたパスワード [36 50 97 36 49 48 36 88 119 122 69 109 49 119 57 76 83 121 84 47 68 101 120 114 78 77 81 46 117 78 105 68 115 114 47 78 81 52 46 74 52 117 84 109 115 68 57 98 89 119 97 46 83 105 83 50 75 70 119 67] コンバート後のパスワード: $2a$10$XwzEm1w9LSyT/DexrNMQ.uNiDsr/NQ4.J4uTmsD9bYwa.SiS2KFwC動作は、問題ないようだ。
■ Token 作成
Token を作成する。
ユーザー認証(login)後に、Token を発行する。
Token は、サービスを利用するためのパスポートみたいなもので、様々な Web サービスで利用されている。
(Linebotを利用するためのアクセストークン など)main.gopackage main import ( "database/sql" "encoding/json" "fmt" "log" "net/http" "github.com/davecgh/go-spew/spew" "github.com/dgrijalva/jwt-go" # JWT パッケージ "golang.org/x/crypto/bcrypt" "github.com/gorilla/mux" "github.com/lib/pq" "github.com/hoge/goAuthentication2/tool" ) // Token 作成関数 func createToken(user User) (string, error) { var err error // 鍵となる文字列(多分なんでもいい) secret := "secret" // Token を作成 // jwt -> JSON Web Token - JSON をセキュアにやり取りするための仕様 // jwtの構造 -> {Base64 encoded Header}.{Base64 encoded Payload}.{Signature} // HS254 -> 証明生成用(https://ja.wikipedia.org/wiki/JSON_Web_Token) token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "email": user.Email, "iss": "__init__", // JWT の発行者が入る(文字列(__init__)は任意) }) //Dumpを吐く spew.Dump(token) tokenString, err := token.SignedString([]byte(secret)) fmt.Println("-----------------------------") fmt.Println("tokenString:", tokenString) if err != nil { log.Fatal(err) } return tokenString, nil } func login(w http.ResponseWriter, r *http.Request) { var user User json.NewDecoder(r.Body).Decode(&user) token, err := createToken(user) if err != nil { log.Fatal(err) } fmt.Println(token) }
go run main.go
で、サーバーを起動する。SignUp 後のユーザーで、localhost:8000/login に Restlet Client でアクセスする。
200 ok になっているので、ログインは成功していると思われる。
コンソールを確認する。
$ go run main.go 2019/05/06 20:35:58 サーバー起動 : 8000 port で受信 (*jwt.Token)(0xc00008f450)({ Raw: (string) "", Method: (*jwt.SigningMethodHMAC)(0xc00000c4c0)({ Name: (string) (len=5) "HS256", Hash: (crypto.Hash) 5 }), Header: (map[string]interface {}) (len=2) { (string) (len=3) "typ": (string) (len=3) "JWT", (string) (len=3) "alg": (string) (len=5) "HS256" }, Claims: (jwt.MapClaims) (len=2) { (string) (len=5) "email": (string) (len=19) "test1@example.co.jp", (string) (len=3) "iss": (string) (len=8) "__init__" }, Signature: (string) "", Valid: (bool) false }) ----------------------------- tokenString: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QxQGV4YW1wbGUuY28uanAiLCJpc3MiOiJfX2luaXRfXyJ9.3tT0YZ-Lk-bgejXyQ5xrg-9JQB1wEmTxQCeVSDknoT4 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QxQGV4YW1wbGUuY28uanAiLCJpc3MiOiJfX2luaXRfXyJ9.3tT0YZ-Lk-bgejXyQ5xrg-9JQB1wEmTxQCeVSDknoT4Token が、{Base64 encoded Header}.{Base64 encoded Payload}.{Signature} の形式で出力されている。
■ Login の実装1
Login を実装する。
main.gofunc login(w http.ResponseWriter, r *http.Request) { var user User var error Error json.NewDecoder(r.Body).Decode(&user) if user.Email == "" { error.Message = "Email は必須です。" errorInResponse(w, http.StatusBadRequest, error) return } if user.Password == "" { error.Message = "パスワードは、必須です。" errorInResponse(w, http.StatusBadRequest, error) } // 認証キー(Emal)のユーザー情報をDBから取得 row := db.QueryRow("SELECT * FROM USERS WHERE email=$1;", user.Email) err := row.Scan(&user.ID, &user.Email, &user.Password) if err != nil { if err == sql.ErrNoRows { // https://golang.org/pkg/database/sql/#pkg-variables error.Message = "ユーザが存在しません。" errorInResponse(w, http.StatusBadRequest, error) } else { log.Fatal(err) } } }
go run main.go
でサーバーを起動して、動作確認を行う。ここまでは問題なさそう。
■ Login の実装2
今度は、token をレスポンス結果に加える。
Login 成功 -> Token 発行。
main.gofunc login(w http.ResponseWriter, r *http.Request) { var user User var error Error var jwt JWT json.NewDecoder(r.Body).Decode(&user) if user.Email == "" { error.Message = "Email は必須です。" errorInResponse(w, http.StatusBadRequest, error) return } if user.Password == "" { error.Message = "パスワードは、必須です。" errorInResponse(w, http.StatusBadRequest, error) } // 追加(この位置であること) password := user.Password fmt.Println("password: ", password) // 認証キー(Emal)のユーザー情報をDBから取得 row := db.QueryRow("SELECT * FROM USERS WHERE email=$1;", user.Email) // ハッシュ化している err := row.Scan(&user.ID, &user.Email, &user.Password) if err != nil { if err == sql.ErrNoRows { // https://golang.org/pkg/database/sql/#pkg-variables error.Message = "ユーザが存在しません。" errorInResponse(w, http.StatusBadRequest, error) } else { log.Fatal(err) } } // 追加(この位置であること) hasedPassword := user.Password fmt.Println("hasedPassword: ", hasedPassword) err = bcrypt.CompareHashAndPassword([]byte(hasedPassword), []byte(password)) if err != nil { error.Message = "無効なパスワードです。" errorInResponse(w, http.StatusUnauthorized, error) return } token, err := createToken(user) if err != nil { log.Fatal(err) } w.WriteHeader(http.StatusOK) jwt.Token = token responseByJSON(w, jwt) }
go run main.go
で、サーバーを起動。Restlet Client から Token が確認できるようになった。
コンソールを確認する。
$ go run main.go 2019/05/06 21:08:04 サーバー起動 : 8000 port で受信 password: golang hasedPassword: $2a$10$XwzEm1w9LSyT/DexrNMQ.uNiDsr/NQ4.J4uTmsD9bYwa.SiS2KFwCちゃんとパスワードが、ハッシュ化されている。
■ 何らかのサービス
Token を使ったサービスの実装が全く思いつかない。
とりあえず、Token を認証するサービス?みたいなものを作る。
main.go// 認証結果をブラウザに返却 func verifyEndpoint(w http.ResponseWriter, r *http.Request) { utils.ResponseJSON(w, "認証OK") } // verifyEndpoint のラッパーみたいなもの func tokenVerifyMiddleWare(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var errorObject Error // HTTP リクエストヘッダーを読み取る authHeader := r.Header.Get("Authorization") // Restlet Client から以下のような文字列を渡す // bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3Q5OUBleGFtcGxlLmNvLmpwIiwiaXNzIjoiY291cnNlIn0.7lJKe5SlUbdo2uKO_iLzzeGoxghG7SXsC3w-4qBRLvs bearerToken := strings.Split(authHeader, " ") fmt.Println("bearerToken: ", bearerToken) if len(bearerToken) == 2 { authToken := bearerToken[1] token, error := jwt.Parse(authToken, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("エラーが発生しました。") } return []byte("secret"), nil }) if error != nil { errorObject.Message = error.Error() errorInResponse(w, http.StatusUnauthorized, errorObject) return } if token.Valid { // レスポンスを返す next.ServeHTTP(w, r) } else { errorObject.Message = error.Error() errorInResponse(w, http.StatusUnauthorized, errorObject) return } } else { errorObject.Message = "Token が無効です。" return } }) } func main() { ... // 追加 router.HandleFunc("/verify", tokenVerifyMiddleWare(verifyEndpoint)).Methods("GET") ... }
go run main.go
でサーバを起動する。前回、取得したトークンを使用して、Restlet Client 上で動作確認を行う。
問題なさそうだ。
■ まとめ
Go 言語に不慣れなので、色々と難しいところがあった。
もっとよく仕様を理解して、実装したい。
- 投稿日:2019-05-06T21:09:00+09:00
golang template in template
https://forum.golangbridge.org/t/nested-index-pipelines-in-golang-templates/6411
working
{{index (index .Amap 123) "key"}}not working
{{index {{index .Amap 123}} "key"}}
- 投稿日:2019-05-06T11:39:30+09:00
goaでPayloadの文字列がログに出ないようにする
iOS/Androidアプリで使うAPIをgoaで作っていて、Payloadの中身をログに出したくない、という案件が出てきたので色々調べた。
参考になった情報
- 続・GoでパスワードなどをPrintfで出力させたくない - kawaken's blog
stringのエイリアスを定義した上で、fmt.Stringer
,fmt.GoStringer
インターフェースを実装することでフォーマットされないようにする。
基本的にはこれで良い。
ただ、goaが自動で出力するログは、一度JSONをパースして構造体に値を入れた後にjson.Marshal
で文字列にしている模様。
https://github.com/goadesign/goa/blob/90bd33edff1a4f17fab06d1f9e14e659075d1328/middleware/log_request.go#L92- Keep passwords and secrets out of your logs with Go
タイトルはやりたいことそのまま。
対象の構造体でjson.Marshaler
を実装し、json.Marshal
の結果を置き換える、というもの。
Payload用の構造体はgoagenで自動生成されるので、そのまま使うことはできないが、↑と組み合わせれば良い感じになりそう。作ったやつ
https://github.com/75py/secretstr
やったこと
参考URLの2つを組み合わせた。
1.string
のエイリアスSecretString
を定義する
2.fmt.Stringer
,fmt.GoStringer
インターフェースを実装する
3.json.Marshaler
インターフェースを実装する
4.string
に戻すための関数を定義する※最後のやつは必須ではない。単に
string(foo)
で戻せるが、「生の文字列を使っているのがどこか?」を簡単に把握できるようにするためには必要かなと。最小限のコードは以下の通り。
type SecretString string func (ss SecretString) String() string { return "[FILTERED]" } func (ss SecretString) GoString() string { return ss.String() } func (ss SecretString) MarshalJSON() ([]byte, error) { return json.Marshal(ss.String()) } func (ss SecretString) RawString() string { return string(ss) }GitHubに置いたコードは、もう少し色気を出して色々書いている。
secret_string.go使い方
基本的には、
string
で定義するところをsecretstr.SecretString
に置き換えるだけで使える。READMEのサンプルコードを参照。
これにより、
fmt.Println(パスワードをメンバに含む構造体)
のようなデバッグコードが仮に残っていたとしても、ログに生のパスワードが出力されることはなくなる。ただ、goagenで自動生成されるコードについては一工夫必要。
goagenで自動生成されるPayloadでの使い方
例えば以下のようにリクエストを投げた場合。
curl -X POST http://localhost:8080/auth/login -d '{"id":"raw_id","password":"raw_password"}' -H "Content-Type: application/json"Before
通常の定義だと、型はただの
string
なので、当然ログにID/PWが出る。design/design.goPayload(func() { Param("id", String, "Login ID") Param("password", String, "Login password") })2019/05/05 21:30:13 [INFO] started req_id=tBkbeBBZ60-1 POST=/auth/login from=::1 ctrl=LoginController action=login 2019/05/05 21:30:13 [INFO] headers req_id=tBkbeBBZ60-1 Accept=*/* Content-Length=41 Content-Type=application/json User-Agent=curl/7.54.0 2019/05/05 21:30:13 [INFO] payload req_id=tBkbeBBZ60-1 raw={"id":"raw_id","password":"raw_password"} 2019/05/05 21:30:13 [INFO] completed req_id=tBkbeBBZ60-1 status=0 bytes=0 time=143.578µs ctrl=LoginController action=loginAfter
ドキュメントを眺めてみると、
Metadata
を定義すれば型を変更できるとのこと。
https://godoc.org/github.com/goadesign/goa/design/apidsl#Metadatadesign/design.goPayload(func() { Param("id", func() { Description("Login ID") Metadata("struct:field:type", "secretstr.SecretString", "github.com/75py/secretstr") }) Param("password", func() { Description("Login password") Metadata("struct:field:type", "secretstr.SecretString", "github.com/75py/secretstr") }) })app/contexts.go(自動生成)// LoginLoginPayload is the login login action payload. type LoginLoginPayload struct { // Login ID ID *secretstr.SecretString `form:"id,omitempty" json:"id,omitempty" yaml:"id,omitempty" xml:"id,omitempty"` // Login password Password *secretstr.SecretString `form:"password,omitempty" json:"password,omitempty" yaml:"password,omitempty" xml:"password,omitempty"` }ログは以下のようになる。
2019/05/05 21:31:15 [INFO] started req_id=tBkbeBBZ60-2 POST=/auth/login/secretstr from=::1 ctrl=LoginController action=login_secretstr 2019/05/05 21:31:15 [INFO] headers req_id=tBkbeBBZ60-2 Accept=*/* Content-Length=41 Content-Type=application/json User-Agent=curl/7.54.0 2019/05/05 21:31:15 [INFO] payload req_id=tBkbeBBZ60-2 raw={"id":"[FILTERED]","password":"[FILTERED]"} 2019/05/05 21:31:15 [INFO] completed req_id=tBkbeBBZ60-2 status=0 bytes=0 time=80.848µs ctrl=LoginController action=login_secretstr少し困っているところ
この方法だと、MaxLengthなどのバリデーションが使えなくなる。
app/contexts.go(自動生成)if utf8.RuneCountInString(payload.ID) > 5 { // cannot use *payload.ID (type secretstr.SecretString) as type string in argument to utf8.RuneCountInString err = goa.MergeErrors(err, goa.InvalidLengthError(`raw.id`, payload.ID, utf8.RuneCountInString(payload.ID), 5, false)) }このくらいなら自分で実装すれば良いので大した手間ではないけど、もっと良い方法があればぜひ教えてください。
- 投稿日:2019-05-06T09:31:17+09:00
Nuxt.js(Vue.js)とGoでSPA + API(レイヤードアーキテクチャ)でチャットアプリを実装してみた
概要
Nuxt.js(Vue.js)とレイヤードアーキテクチャのお勉強のために簡単なチャットアプリを実装してみた。
SPA + APIと言った形になっている。機能
機能はだいたい以下のような感じ。
- ログイン機能
- サインアップ機能
- スレッド一覧表示機能
- スレッド作成機能
- ログインしたユーザーは誰でもスレッドを作成できること
- コメント一覧表示機能
- スレッドをクリックすると、そのスレッド内のコメント一覧が表示されること
- スレッド内でのコメント作成機能
- ログインしたユーザーは誰でもどのスレッド内でもコメントできること
- スレッド内でのコメント削除機能
- 自分のコメントのみ削除できること
- ログアウト機能
コード
- コード全体はここ
- コードは一例でもっと他の実装や良さそうな実装はありそう
技術
サーバーサイド
アーキテクチャ
DDD本に出てくるレイヤードアーキテクチャをベースに以下の書籍や記事を参考にさせていただき実装した。超厳密なレイヤードアーキテクチャというわけではない。
- Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート - BASE開発チームブログ
- GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck
- ボトムアップドメイン駆動設計 │ nrslib
- エリック・エヴァンス(著)、今関 剛 (監修)、和智 右桂 (翻訳) (2011/4/9)『エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)』 翔泳社
- pospome『pospomeのサーバサイドアーキテクチャ
実際のpackage構成は以下のような感じ。
├── interface │ └── controller // サーバへの入力と出力を扱う責務。 ├── application // 作業の調整を行う責務。 ├── domain │ ├── model // ビジネスの概念とビジネスロジック(正直今回はそんなにビジネスロジックない...) │ ├── service // EntityでもValue Objectでもないドメイン層のロジック。 │ └── repository // infra/dbへのポート。 ├── infra // 技術に関すること。 │ ├── db // DBの技術に関すること。 │ ├── logger // Logの技術に関すること。 │ └── router // Routingの技術に関すること。 ├── middleware // リクエスト毎に差し込む処理をまとめたミドルウェア ├── util └── testutilpackageの切り方は以下を大変参考にさせていただいている。
- エリック・エヴァンス(著)、今関 剛 (監修)、和智 右桂 (翻訳) (2011/4/9)『エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)』 翔泳社
- pospome『pospomeのサーバサイドアーキテクチャ』
- Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート - BASE開発チームブログ
- ボトムアップドメイン駆動設計 │ nrslib
- GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck
上記のpackage以外に
application/mock
、domain/service/mock
、infra/db/mock
というmockを格納する用のpackageもあり、そこに各々のレイヤーのmock用のファイルを置いている。(詳しくは後述)依存関係
依存関係としてはざっくり、
interface/controller
→application
→dmain/repository
ordmain/service
←infra/db
という形になっている。参考: GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck
domain/~
とinfra/db
で矢印が逆になっているのは、依存関係が逆転しているため。
詳しくは その設計、変更に強いですか?単体テストできますか?...そしてクリーンアーキテクチャ - Qiitaを参照。先ほどの矢印の中で、
domain/model
は記述しなかったが、domain/model
は、interface/controller
やapplication
等からも依存されている。純粋なレイヤードアーキテクチャでは、各々のレイヤーは自分の下のレイヤーにのみ依存するといったものがあるかもしれないが、それを実現するためにDTO等を用意する必要があって、今回の実装ではそこまで必要はないかなと思ったためそうした。(厳格にやる場合は、実装した方がいいかもしれない)各レイヤーでのinterfaceの定義とテスト
applicaion
、domain/service
、infra/db
(定義先は、/domain/repository
) にはinterface
を定義し、他のレイヤーからはそのinterface
に依存させるようにしている。こうするとこれらを使用する側は、抽象に依存するようになるので、抽象を実装する具象を変化させても使用する側(依存する側)はその影響を受けにくい。実際に各レイヤーを使用する側のレイヤのテストの際には、使用されるレイヤーを実際のコードではなく、Mock用のものに差し替えている。各々のレイヤーに存在する
mock
というpackageにmock用のコードを置いている。このモック用のコードは、gomockを使用して自動生成している。この辺のことについては、
その設計、変更に強いですか?単体テストできますか?...そしてクリーンアーキテクチャ - Qiita という記事を以前書いたので、詳しくはこちらを参照いただきたい。エラーハンドリング
エラーハンドリングは以下のように行なっている。
- 以下のような形で
errors.Wrap
を使用してオリジナルのエラーを包むif err := Hoge(); err != nil { return errors.Wrap(オリジナルエラー, "状況の説明" }
- 独自のエラー型を定義している
- エラーは基本的に各々のレイヤーで握りつぶさず、
interface/controller
レイヤーまで伝播させる- 最終的には、
interface/controller
でエラーの型によって、レスポンスとして返すメッセージやステータスコードを選択する参考
Golangのエラー処理とpkg/errors | SOTAログイン周り
- 外部サービスを使用せず、自前で簡単なものを実装した
- パスワードのハッシュ化には bcryptを使用した
- 普通にCookieとSessionを使用した
- ログインが必要なAPIには
gin
のmiddleware
を使用して、ログイン済みでないクライアントからのリクエストは401 Unauthorized
を返すようにしたDB周り
- MySQLを使用した
- DBテスト部分は、DBサーバを立てたわけではなく、DATA-DOG/go-sqlmockを使用し、モックで行なった
- GoのAPIのテストにおける共通処理 – timakin – Mediumにあるように以下等を使用してDBサーバーを立てて行うのも良いかも
- ory/dockertest
- Dockerを使う場合
- lestrrat-go/test-mysqld
- Dockerを使わない場合
- DB操作周りの実装に関しては、database/sql packageをそのまま使用し、ORMやその他のライブラリは特に使用していない
- トランザクションは、
application
レイヤでかける- 以下のようなSQL周りの
interface
を作成package query import ( "context" "database/sql" ) // DBManager is the manager of SQL. type DBManager interface { SQLManager Beginner } // TxManager is the manager of Tx. type TxManager interface { SQLManager Commit() error Rollback() error } // SQLManager is the manager of DB. type SQLManager interface { Querier Preparer Executor } type ( // Executor is interface of Execute. Executor interface { Exec(query string, args ...interface{}) (sql.Result, error) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) } // Preparer is interface of Prepare. Preparer interface { Prepare(query string) (*sql.Stmt, error) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) } // Querier is interface of Query. Querier interface { Query(query string, args ...interface{}) (*sql.Rows, error) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) } // Beginner is interface of Begin. Beginner interface { Begin() (TxManager, error) } )
application
レイヤーでは以下のようにフィールドでquery.DBManager
を所持する
- そうすることで
SQLManager
とTxManager
(Begin()
で生成)のどちらもapplication
レイヤーで扱うことができる(application
レイヤで直接使用するわけではなく、domain/repository
に渡す)// threadService is application service of thread. type threadService struct { m query.DBManager service service.ThreadService repo repository.ThreadRepository txCloser CloseTransaction }
domain/repository
の引数ではquery.SQLManager
を受け取る
query.TxManager
は、query.SQLManager
も満たしているので、query.TxManager
は、query.SQLManager
のどちらも受け取ることができる// ThreadRepository is Repository of Thread. type ThreadRepository interface { ListThreads(ctx context.Context, m query.SQLManager, cursor uint32, limit int) (*model.ThreadList, error) GetThreadByID(ctx context.Context, m query.SQLManager, id uint32) (*model.Thread, error) GetThreadByTitle(ctx context.Context, m query.SQLManager, name string) (*model.Thread, error) InsertThread(ctx context.Context, m query.SQLManager, thead *model.Thread) (uint32, error) UpdateThread(ctx context.Context, m query.SQLManager, id uint32, thead *model.Thread) error DeleteThread(ctx context.Context, m query.SQLManager, id uint32) error }
- 以下のようなRollbackやCommitを行う関数を作成しておく
// CloseTransaction executes post process of tx. func CloseTransaction(tx query.TxManager, err error) error { if p := recover(); p != nil { // rewrite panic err = tx.Rollback() err = errors.Wrap(err, "failed to roll back") panic(p) } else if err != nil { err = tx.Rollback() err = errors.Wrap(err, "failed to roll back") } else { err = tx.Commit() err = errors.Wrap(err, "failed to commit") } return err }
application
レイヤでは、defer
でCloseTransaction
を呼び出す(ここではa.txCloser
になっている)// CreateThread creates Thread. func (a *threadService) CreateThread(ctx context.Context, param *model.Thread) (thread *model.Thread, err error) { tx, err := a.m.Begin() if err != nil { return nil, beginTxErrorMsg(err) } defer func() { if err := a.txCloser(tx, err); err != nil { err = errors.Wrap(err, "failed to close tx") } }() yes, err := a.service.IsAlreadyExistTitle(ctx, tx, param.Title) if yes { err = &model.AlreadyExistError{ PropertyName: model.TitleProperty, PropertyValue: param.Title, DomainModelName: model.DomainModelNameThread, } return nil, errors.Wrap(err, "already exist id") } if _, ok := errors.Cause(err).(*model.NoSuchDataError); !ok { return nil, errors.Wrap(err, "failed is already exist id") } id, err := a.repo.InsertThread(ctx, tx, param) if err != nil { return nil, errors.Wrap(err, "failed to insert thread") } param.ID = id return param, nil }
- 上記の処理ができるように
CloseTransaction
をapplication
レイヤの構造体にDIしておく
- Goでは関数もDIできる
// threadService is application service of thread. type threadService struct { m query.DBManager service service.ThreadService repo repository.ThreadRepository txCloser CloseTransaction }所感
- レイヤードアーキテクチャは
- 依存関係がはっきりするのが良い
- 各レイヤが疎結合なので変更しやすく、テストもしやすいのは良い
- 各レイヤの責務がはっきり別れているので、どこに何を書けばいいかはわかりやすい
- コード量は増えるので、実装に時間がかかる
- 決まったところは自動化できると良いかも
- CRUDだけの小さなアプリケーションでは、大げさすぎるかもしれない
フロントエンド
アーキテクチャ
- 基本的には、Nuxt.jsのアーキテクチャに沿って実装を行なった
- 状態管理に感じては、Vuexを使用した
- 各々の
Component
側(pages
やcomponents
)からデータを使用したい場合には、Vuexを通じて使用した- データ、ロジックとビュー部分が綺麗に別れる
見た目
- Vue.jsに全面的に乗っかった
- コメントの一覧部分のCSSは CSSで作る!吹き出しデザインのサンプル19選 を参考にさせていただいた
大きな流れ
大きな流れとしては、以下のような流れ。
pasges
やcomponents
等のビューでのイベントの発生 →actions
経由でAPIへリクエスト →mutations
でstate
変更 →pasges
やcomponents
等のビューに反映される他の流れもたくさんあるが、代表的なList処理とInput処理の流れを以下に記す。
List処理
pages
やcomponents
のasyncData
内で、store.dispatch
を通じて、データ一覧を取得するアクション(actions
)を呼び出すstore
のactions
内での処理を行う
- axiosを使用してAPIにリクエストを送信する
- APIから返却されたデータを引数に
mutations
をcommit
する。mutations
での処理を行う
state
を変更するpages
やcomponents
のビューで取得したデータが表示されるInput処理
pages
やcomponents
でstores
に定義したaction
やstate
を読み込んでおくpages
やcomponents
のdata
部分とformのinput部分等にv-model
を使用して双方向データバインディングをしておくpages
やcomponents
で表示しているビュー部分でイベントが生じる
- form入力→submitなど
- sumitする時にクリックされるボタンに
@click=hoge
という形でイベントがそのElementで該当のイベントが生じた時に呼び出されるメソッド等を登録しておく
- 上記の例では、
click
イベントが生じるとhoge
メソッドが呼び出される- イベントハンドリング — Vue.js
- 呼び出されたメソッドの処理を行う
- formのデータを元にデータを登録するアクション(
actions
)を呼び出すstore
のactions
内での処理を行う
- axiosを使用してAPIにリクエストを送信する
- APIから返却されたデータを引数に
mutations
をcommit
する。mutations
での処理を行う
state
を変更する- 登録した分のデータを一覧の
state
に追加するpages
やcomponents
のビューで登録したデータが追加された一覧表示される非同期部分
async/await
で処理所感
- Nuxt.jsを使用すると、レールに乗っかれて非常に楽
- どこに何を実装すればいいか明白になるので迷わないで済む
- 特にVuexを使用すると
- データの流れが片方向になるのはわかりやすくて良い
- ビュー、ロジック、データの責務がはっきりするのが良い
- Vuetifyを使用するとあまり凝らない画面であれば、短期間で実装できそう
- Componentの切り方をAtomic Designに則ったやり方とかにするともっといい感じに切り分けられたかもしれない
参考文献
サーバーサイド
- InfoQ.com、徳武 聡(翻訳) (2009年6月7日) 『Domain Driven Design(ドメイン駆動設計) Quickly 日本語版』 InfoQ.com
- エリック・エヴァンス(著)、今関 剛 (監修)、和智 右桂 (翻訳) (2011/4/9)『エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)』 翔泳社
- pospome『pospomeのサーバサイドアーキテクチャ』
フロントエンド
- 花谷拓磨 (2018/10/17)『Nuxt.jsビギナーズガイド』シーアンドアール研究所
- 川口 和也、喜多 啓介、野田 陽平、 手島 拓也、 片山 真也(2018/9/22)『Vue.js入門 基礎から実践アプリケーション開発まで』技術評論社
参考にさせていただいた記事
サーバーサイド
Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート - BASE開発チームブログ
GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck
フロントエンド
関連記事
- 投稿日:2019-05-06T00:06:51+09:00
【Golang:Ginkgo】BDDがちょうどいいかもしれない。(実践編2)
お題
前回書いたBDD(by Ginkgo)の残り作業。
当記事は、↑を見ていることが前提。「Ginkgo」というツールがもたらす体裁(BDDテストフレームワーク)を使って「機能を実装するスピードが最優先という状況下での品質担保」を(あまりコストをかけずに)出来ないか?というもの。
前回、テストコードを書く時にネックになる「RDBのような外部サービスに依存するコードをどのようにテストするか」に関して、DIP(依存性逆転の原則)を意識した切り口でテストコードを書くところまでやった。
今回は、前回書けなかった「実際にRDBに接続するプロダクトコード」の方を書く。
とともに、前回同じく書いていないmain.go
からのusecase
パッケージ呼び出しまでの流れも書く。開発環境
# OS
$ cat /etc/os-release NAME="Ubuntu" VERSION="18.04.2 LTS (Bionic Beaver)"# Golang
$ go version go version go1.11.4 linux/amd64# Ginkgo/Gomega
$ cat go.mod module gobdd require ( github.com/onsi/ginkgo v1.8.0 github.com/onsi/gomega v1.5.0 )実践
前回終了時点のプロジェクト構成
$ tree . ├── adapter │ └── gateway │ ├── gcp │ │ └── notice.go │ ├── local │ │ └── notice.go │ └── test │ └── notice.go ├── domain │ ├── model │ │ └── notice.go │ └── notice.go ├── gobdd_suite_test.go ├── go.mod ├── go.sum ├── notice_test.go └── usecase ├── model │ └── notice.go └── notice.go今回のプロダクトコード実装後のプロジェクト構成
$ tree . ├── adapter │ ├── gateway + │ │ ├── gateway.go │ │ ├── gcp * │ │ │ └── notice.go │ │ ├── local * │ │ │ └── notice.go │ │ └── test │ │ └── notice.go + │ └── middleware + │ ├── model + │ │ └── notice.go + │ └── persistence + │ └── rdb.go + ├── cmd + │ └── main.go + ├── docker-compose.yml ├── domain │ ├── model │ │ └── notice.go │ └── notice.go + ├── global + │ └── setting.go ├── gobdd_suite_test.go ├── go.mod ├── go.sum + ├── handler + │ ├── notice.go + │ └── router.go + ├── local + │ └── init + │ └── 1_create.sql ├── notice_test.go └── usecase ├── model │ └── notice.go └── notice.go前回は”ユースケース”をベースとしたテストコードを1例あげることにのみ注力したのでファイル数は少なかったが、
今回は実際に(少なくともローカルでは)アプリを起動してWebAPIとしてリクエストを受け付けるところまで確認できるようにしたので、だいぶファイル数が増えた。■事前検討
前回書いた部分でのパッケージ呼び出し階層は下記。
usecase[具象] -> domain[抽象] ↑ adapter/gateway[具象]
adapter
は、同じ機能に対し、目的(本番なのかテストなのかローカルでの動作確認なのか)に応じて処理(ないし接続先)を切り替えるためのパッケージ。
usecase
層のテストコードにおいては、実行時にテスト用のadapter
をusecase
にセットできるので容易に処理の切り替えが可能。
それに対して、実際のプロダクトコードは、そもそもmain.go
から始まり、(当ケースで言うと)WebAPIとして機能を提供するので、WebアプリとしてHTTPリクエストを受け付けるロジックの実装が必要。
その中の特定のエンドポイントでHTTPリクエストを受け付けるロジックにて、上記usecase
層のロジックを呼び出すことになる。
つまり、main.go
〜usecase
層ロジック呼び出しまでの間に、どのadapter
を使用するかを何かの条件で決定する必要がある。今回、プロダクトコードの実装では、
main.go
とusecase
層の呼び出し間に(パッケージ名で)handler
という層を設けた。
この層では、WebAPIサーバとして起動したアプリで各エンドポイントを提供する際の個々のリクエスト処理を担う。
この層で、usecase
層のロジックを呼び出すとともに、必要なadapter
をセットする。■アプリ起動ロジック
何はともあれ、アプリ起動ロジックから。
main.go
[cmd/main.go]package main import ( "fmt" "gobdd/adapter/middleware/persistence" "gobdd/global" "gobdd/handler" "os" _ "github.com/go-sql-driver/mysql" "github.com/labstack/echo" ) func init() { global.InitIsLocal(os.Getenv("IS_LOCAL") != "") } func main() { // データベース接続ソース文字列 var dataSource string if global.IsLocal() { // ローカル環境の場合は docker-compose の設定でMySQL起動するので固定値 dataSource = "localuser:localpass@tcp(127.0.0.1)/localdb?charset=utf8&parseTime=True&loc=Local" } else { // GCP - CloudSQL への接続情報は環境変数から取得 var ( connectionName = os.Getenv("CLOUDSQL_CONNECTION_NAME") user = os.Getenv("CLOUDSQL_USER") password = os.Getenv("CLOUDSQL_PASSWORD") database = os.Getenv("CLOUDSQL_DATABASE") ) dataSource = fmt.Sprintf("%s:%s@unix(/cloudsql/%s)/%s?parseTime=True", user, password, connectionName, database) } // RDB接続状態の管理用ツールを隠蔽するミドルウェア rdbMiddleware, err := persistence.NewRDBMiddleware(dataSource) if err != nil { panic(err) } defer func() { if err := rdbMiddleware.Close(); err != nil { panic(err) } }() e := echo.New() e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { c.Set(handler.PersistenceKey, rdbMiddleware) return next(c) } }) handler.Routing(e) port := "8080" // Google App Engineにデプロイする場合、デプロイ先で適切なポートが設定される。 if s := os.Getenv("PORT"); s != "" { port = s } e.Logger.Fatal(e.Start(":" + port)) }説明:「ローカル・デプロイ先の切り替え」
今回のプロダクトコードでは、ローカル環境で動作する場合とデプロイ後(GCPのApp Engineを想定)に動作する場合とでロジックを切り替えられるようにすることがポイントのひとつ。
今起動しているのがローカル環境か否かは環境変数「IS_LOCAL
」のセット有無で判定させる。アプリ起動時に判定後、判定結果はグローバル変数に持たせておく。↓[global/setting.go]package global import ( "sync" ) var isLocal bool var isLocalOnce sync.Once func IsLocal() bool { return isLocal } func InitIsLocal(f bool) { // 当関数が何度呼ばれても1度きりしかフラグセットされないことを保証 isLocalOnce.Do(func() { isLocal = f }) }説明:「データベース接続」
ついで、アプリ起動時にデータベース接続を行っておく。
ユーザ名やパスワード、DB名といった接続情報は、やはり環境変数から取得するが、それはデプロイ時のみとし、ローカル環境では固定値とする。dataSource = fmt.Sprintf("%s:%s@unix(/cloudsql/%s)/%s?parseTime=True", user, password, connectionName, database)※ローカルでは
docker-compose
を使って上記設定でローカルにコンテナ内MySQLを立ち上げるので。データベース接続ロジックはgormというライブラリを使っているが、後に他のライブラリに切り替えることも想定してラッパーを用意しておく。
(が、どこまでのラップをするかや、どこまでの切り替えコストを許容するかなどは特に考えていない。)rdbMiddleware, err := persistence.NewRDBMiddleware(dataSource)New関数を含むラッパーのソースは下記。
まあ、本当はコネクションプール数の設定など、もろもろ必要になるが、サンプルソースなので省略。[middleware/persistence/rdb.go]package persistence import ( "errors" "github.com/jinzhu/gorm" ) func NewRDBMiddleware(dataSource string) (RDBMiddleware, error) { // データベース接続 dbConn, err := gorm.Open("mysql", dataSource) if err != nil { return nil, err } if dbConn == nil { return nil, errors.New("can not connect to Cloud SQL") } dbConn.LogMode(true) if err := dbConn.DB().Ping(); err != nil { return nil, err } return &rdbMiddleware{dbConn: dbConn}, nil } type RDBMiddleware interface { Create(v interface{}) error Close() error } type rdbMiddleware struct { dbConn *gorm.DB } func (p *rdbMiddleware) Create(v interface{}) error { return p.dbConn.Save(v).Error } func (p *rdbMiddleware) Close() error { if p == nil { return nil } if p.dbConn == nil { return nil } return p.dbConn.Close() }説明:「WebAPIサーバ」
EchoというGoのWebフレームワークを採用。
先述したデータベース接続リソースを各リクエスト処理時に使えるようEchoのコンテキストにセットしておく。e := echo.New() e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { c.Set(handler.PersistenceKey, rdbMiddleware) return next(c) } })以下のルーティングについては後述。
handler.Routing(e)■ルーティングとユースケース呼び出し
リクエストを受け付けるエンドポイントは以下にて定義。
[handler/routing.go]package handler import ( "net/http" "github.com/labstack/echo" ) const PersistenceKey = "PERSISTENCE" func Routing(e *echo.Echo) { http.Handle("/", e) HandleNotice(e) }[handler/notice.go]package handler import ( "gobdd/adapter/gateway" "gobdd/adapter/middleware/persistence" "gobdd/usecase" usecasemodel "gobdd/usecase/model" "net/http" "github.com/labstack/echo" ) func HandleNotice(g *echo.Echo) { g.POST("/notice", createNotice) } func createNotice(c echo.Context) error { contextVal := c.Get(PersistenceKey) rdbMiddleware, ok := contextVal.(persistence.RDBMiddleware) if !ok { return c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) } // HTTPリクエストパラメータ(JSON形式のBodyを想定)を構造体にマッピング var form *noticeForm if err := c.Bind(&form); err != nil { return c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) } id, err := usecase.NewNotice(gateway.NewNotice(rdbMiddleware)).Create(form.ConvertToUsecaseModel()) if err != nil { return c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) } return c.JSON(http.StatusOK, struct { Code int `json:"code"` // HTTPステータスコード Message string `json:"text"` // HTTPステータスメッセージ ID string `json:"id"` // 『お知らせ』のユニークID }{ Code: http.StatusOK, Message: http.StatusText(http.StatusOK), ID: id, }) } type noticeForm struct { Title string `json:"title"` // お知らせのタイトル Text string `json:"text"` // お知らせの文章(現時点はテキストのみサポート) PublishFrom int `json:"publish_from"` // お知らせの掲載開始日時 PublishTo int `json:"publish_to"` // お知らせの掲載終了日時 } func (f *noticeForm) ConvertToUsecaseModel() *usecasemodel.Notice { return &usecasemodel.Notice{ Title: f.Title, Text: f.Text, PublishFrom: f.PublishFrom, PublishTo: f.PublishTo, } }「
/notice
」にJSONをPOSTされることを想定している。
main.go
にてセットしていたRDB接続リソース(のラッパー)を取得する。↓contextVal := c.Get(PersistenceKey) rdbMiddleware, ok := contextVal.(persistence.RDBMiddleware)HTTPリクエストパラメータは下記の通りJSON形式を想定した構造体にマッピング。
var form *noticeForm if err := c.Bind(&form); err != nil {type noticeForm struct { Title string `json:"title"` // お知らせのタイトル Text string `json:"text"` // お知らせの文章(現時点はテキストのみサポート) PublishFrom int `json:"publish_from"` // お知らせの掲載開始日時 PublishTo int `json:"publish_to"` // お知らせの掲載終了日時 }ユースケース呼び出しは下記の通り。
id, err := usecase.NewNotice(gateway.NewNotice(rdbMiddleware)).Create(form.ConvertToUsecaseModel())
gateway.NewNotice
は下記のように、ローカル環境か否かで使用する構造体を切り替えている。[adapter/gateway/gateway.go]package gateway import ( gcpgateway "gobdd/adapter/gateway/gcp" localgateway "gobdd/adapter/gateway/local" "gobdd/adapter/middleware/persistence" "gobdd/domain" "gobdd/global" ) func NewNotice(rdbMiddleware persistence.RDBMiddleware) domain.Notice { var n domain.Notice if global.IsLocal() { n = localgateway.NewNotice(rdbMiddleware) } else { n = gcpgateway.NewNotice(rdbMiddleware) } return n }■アダプターとしてのローカル・GCP環境ドメイン処理
gateway.NewNotice()
内でローカル環境か否かで切り替えられている構造体の定義。local/notice.go
[adapter/gateway/local/notice.go]package localgateway import ( middlewaremodel "gobdd/adapter/middleware/model" "gobdd/adapter/middleware/persistence" "gobdd/domain" domainmodel "gobdd/domain/model" "time" ) func NewNotice(rdbMiddleware persistence.RDBMiddleware) domain.Notice { return ¬iceImpl{rdbMiddleware: rdbMiddleware} } type noticeImpl struct { rdbMiddleware persistence.RDBMiddleware } func (n *noticeImpl) Create(noticeModel *domainmodel.Notice) (string, error) { m := &middlewaremodel.Notice{ ID: noticeModel.ID, Title: noticeModel.Title, Text: noticeModel.Text, PublishFrom: noticeModel.PublishFrom, PublishTo: noticeModel.PublishTo, CreatedAt: time.Now(), } if err := n.rdbMiddleware.Create(m); err != nil { return "", err } return noticeModel.ID, nil }gcp/notice.go
現時点ではローカル環境との差分はパッケージ名以外ないので省略。
ローカル環境での動作確認
docker-composeによるMySQLコンテナ起動(※開発端末がLinux(Ubuntu)であることが前提)
docker-compose.yml
があるパス上で。$ sudo docker-compose up Creating network "gobdd_default" with the default driver Creating gobdd_db_1_7d8a90153e34 ... done Attaching to gobdd_db_1_1d9095d7bd8a 〜〜 〜〜MySQLコンテナ内でテーブル状況確認
$ sudo docker ps [sudo] password for sky0621: CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 8dab77d1b139 mysql:5.7.24 "docker-entrypoint.s…" 3 hours ago Up 3 hours 0.0.0.0:3306->3306/tcp, 33060/tcp gobdd_db_1_1d9095d7bd8a $ $ sudo docker exec -it 8dab77d1b139 /bin/sh # # # mysql -u localuser -p Enter password: Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 7 Server version: 5.7.24 MySQL Community Server (GPL) Copyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved. Oracle is a registered trademark of Oracle Corporation and/or its affiliates. Other names may be trademarks of their respective owners. Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. mysql> use localdb Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed mysql> show tables; +-------------------+ | Tables_in_localdb | +-------------------+ | notice | +-------------------+ 1 row in set (0.00 sec) mysql> mysql> desc notice; +--------------+--------------+------+-----+-------------------+-----------------------------+ | Field | Type | Null | Key | Default | Extra | +--------------+--------------+------+-----+-------------------+-----------------------------+ | id | varchar(64) | NO | PRI | NULL | | | title | varchar(256) | NO | | NULL | | | text | varchar(256) | NO | | NULL | | | publish_from | int(11) | YES | | NULL | | | publish_to | int(11) | YES | | NULL | | | created_at | timestamp | NO | | CURRENT_TIMESTAMP | | | updated_at | timestamp | YES | | NULL | on update CURRENT_TIMESTAMP | +--------------+--------------+------+-----+-------------------+-----------------------------+ 7 rows in set (0.00 sec) mysql> select * from notice; Empty set (0.00 sec)WebAPIサーバ起動
cmd
パッケージの下で。$ go run main.go ____ __ / __/___/ / ___ / _// __/ _ \/ _ \ /___/\__/_//_/\___/ v3.3.10-dev High performance, minimalist Go web framework https://echo.labstack.com ____________________________________O/_______ O\ ⇨ http server started on [::]:8080curlで動作確認
$ curl -X POST \ > http://localhost:8080/notice \ > -H 'Content-Type: application/json' \ > -H 'cache-control: no-cache' \ > -d '{ > "title": "ローカルお知らせ1", > "text": "ローカルのお知らせ1です。" > }'; {"code":200,"text":"OK","id":"c9df1acd-0066-45a4-9e86-a0d940bbf30c"}DBの確認
mysql> select * from notice; +--------------------------------------+-----------------+------------------------+--------------+------------+---------------------+---------------------+ | id | title | text | publish_from | publish_to | created_at | updated_at | +--------------------------------------+-----------------+------------------------+--------------+------------+---------------------+---------------------+ | c9df1acd-0066-45a4-9e86-a0d940bbf30c | ローカルお知らせ1 | ローカルのお知らせ1です。 | 0 | 0 | 2019-05-05 23:57:23 | 2019-05-05 23:57:23 | +--------------------------------------+-----------------+------------------------+--------------+------------+---------------------+---------------------+ 1 row in set (0.00 sec)まとめ
「BDDが〜〜」と言いつつ、今回は前回の残作業であるプロダクトコードの実装部分だったので、BDD関係ないものになってしまった・・・。
(そして、結局、google/wire
も使わず仕舞い。)今回のソース全量は下記。
https://github.com/sky0621/gobdd/tree/5e822955465705b7808e883f4655f8f81793ecc2