20190506のGoに関する記事は6件です。

入力項目の意味を調べる

前回書いた以下`hello.go'の中身、特に前半に書いてある以下3つの意味をちゃんと知っておきたい。内容を読めばなんとなく分かるけど、所詮なんとなくなので。

  1. package main
  2. import "fmt"
  3. func main()
package main

import "fmt"

func main() {
        fmt.Println("hi.this is small step,but will be big one for me.")
    }

文言を検索すると色々と解説しているサイトが出てくるが残念ながら自分にはちゃんと理解出来なかった。今日は遠方から戻ってきて疲れたのでまた明日調べ直す。そんな訳でお休みなさい。

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

Go言語で認証機能を作ろう!

学習履歴

■ はじめに

Go 言語で、API を作る。

職場で、とある API を利用したことがある。

そのサービスは、以下のような仕組みになっていた。

  1. ユーザー登録
  2. ユーザー認証
  3. 認証後、token を発行
  4. 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.go
package 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.go
package 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.go
func signup(w http.ResponseWriter, r *http.Request) {

あと、起動確認をするため、Restlet Client を Chrome にインストールしよう。

コンソール上で、以下のコマンドを実行する。

# サーバー起動
$ go run main.go 
2019/05/05 18:54:41 サーバー起動 : 8000 port で受信

スクリーンショット 2019-05-05 19.01.36.png

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.go
type 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 を作成する。
* 筆者は無料枠を使ってます! 有料もあるので、気をつけてください。

スクリーンショット 2019-05-06 17.07.19.png

スクリーンショット 2019-05-06 17.07.55.png

スクリーンショット 2019-05-06 17.08.18.png

スクリーンショット 2019-05-06 17.08.33.png

golang-api という Instance を作成した。
作成に成功すると以下のような画面に入ることができる。

スクリーンショット 2019-05-06 17.13.50.png

画像に表示されている URL はあとで使う。

サブタイトルに BROWSER があり、ここから SQL を発行できる。

スクリーンショット 2019-05-06 17.18.57.png

# データベースを作成
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;

スクリーンショット 2019-05-06 17.21.26.png

GolangのDBの操作で、 go-sqlite3 を使った方法を勉強したので、こっちでも良かったが、URL を使って、データベースの登録ができるので、今回はこのサービスを使う。

■ DB 接続

以下は必須の作業ではないが、個人情報格納用のフォルダとファイルを作成する。

mkdir tool
touch tool/params.go
params.go
package tool

type Info struct {
    dburl string
}

func (u Info) GetDBUrl() string {
     // elephantSQL の Detail に表示されている URL を記述
    return "postgres:hogehoge"
}

続いて、elephantsqlのサイトに、Go による DB 接続のサンプルコードがあるので、これを参考に DB に接続する。

main.go
package 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 形式で実行する。

スクリーンショット 2019-05-06 17.49.04.png

実行後、「successfully called signup」が表示されたので、コードは問題なさそう。

<参考>
pq

■ SignUp の実装1

SignUp を実装する。

SignUp は、ユーザーを新規に登録する機能だ。

ちなみに、SignIn は、既存ユーザーを認証する機能らしい。

main.go
package 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"
}

スクリーンショット 2019-05-06 18.08.25.png

とりあえず、エラーになっていない。

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 関数で、エラーハンドリングを行なっている。

スクリーンショット 2019-05-06 18.23.17.png

スクリーンショット 2019-05-06 18.23.27.png

■ SignUp の実装2

Sign Up の実装の続きだ。

具体的には、パスワードの暗号化とデータベースの登録処理を実装する。

main.go
package 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 で、動作確認を行う。

スクリーンショット 2019-05-06 18.49.15.png

ElephantSQl も確認する。

スクリーンショット 2019-05-06 18.49.26.png

コンソールも確認する。

---------------------
パスワード:  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.go
package 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 でアクセスする。

スクリーンショット 2019-05-06 20.36.29.png

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-9JQB1wEmTxQCeVSDknoT4

Token が、{Base64 encoded Header}.{Base64 encoded Payload}.{Signature} の形式で出力されている。

■ Login の実装1

Login を実装する。

main.go
func 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 でサーバーを起動して、動作確認を行う。

<登録済みユーザー>
スクリーンショット 2019-05-06 20.55.06.png

<存在しないユーザー>
スクリーンショット 2019-05-06 20.55.54.png

<その他>
スクリーンショット 2019-05-06 20.56.58.png

スクリーンショット 2019-05-06 20.57.16.png

ここまでは問題なさそう。

■ Login の実装2

今度は、token をレスポンス結果に加える。

Login 成功 -> Token 発行。

main.go
func 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 が確認できるようになった。

スクリーンショット 2019-05-06 21.10.44.png

コンソールを確認する。

$ 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 上で動作確認を行う。

スクリーンショット 2019-05-06 21.45.22.png

問題なさそうだ。

■ まとめ

Go 言語に不慣れなので、色々と難しいところがあった。
もっとよく仕様を理解して、実装したい。

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

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"}}

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

goaでPayloadの文字列がログに出ないようにする

iOS/Androidアプリで使うAPIをgoaで作っていて、Payloadの中身をログに出したくない、という案件が出てきたので色々調べた。

参考になった情報

作ったやつ

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.go
Payload(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=login

After

ドキュメントを眺めてみると、Metadataを定義すれば型を変更できるとのこと。
https://godoc.org/github.com/goadesign/goa/design/apidsl#Metadata

design/design.go
Payload(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))
    }

このくらいなら自分で実装すれば良いので大した手間ではないけど、もっと良い方法があればぜひ教えてください。

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

Nuxt.js(Vue.js)とGoでSPA + API(レイヤードアーキテクチャ)でチャットアプリを実装してみた

概要

Nuxt.js(Vue.js)とレイヤードアーキテクチャのお勉強のために簡単なチャットアプリを実装してみた。
SPA + APIと言った形になっている。

機能

機能はだいたい以下のような感じ。

  • ログイン機能
  • サインアップ機能
  • スレッド一覧表示機能
  • スレッド作成機能
    • ログインしたユーザーは誰でもスレッドを作成できること
  • コメント一覧表示機能
    • スレッドをクリックすると、そのスレッド内のコメント一覧が表示されること
  • スレッド内でのコメント作成機能
    • ログインしたユーザーは誰でもどのスレッド内でもコメントできること
  • スレッド内でのコメント削除機能
    • 自分のコメントのみ削除できること
  • ログアウト機能

コード

  • コード全体はここ
  • コードは一例でもっと他の実装や良さそうな実装はありそう

技術

サーバーサイド

アーキテクチャ

DDD本に出てくるレイヤードアーキテクチャをベースに以下の書籍や記事を参考にさせていただき実装した。超厳密なレイヤードアーキテクチャというわけではない。

実際のpackage構成は以下のような感じ。

├── interface
│   └── controller // サーバへの入力と出力を扱う責務。
├── application // 作業の調整を行う責務。
├── domain
│   ├── model // ビジネスの概念とビジネスロジック(正直今回はそんなにビジネスロジックない...)
│   ├── service // EntityでもValue Objectでもないドメイン層のロジック。
│   └── repository // infra/dbへのポート。
├── infra // 技術に関すること。
│    ├── db // DBの技術に関すること。
│    ├── logger // Logの技術に関すること。
│    └── router // Routingの技術に関すること。 
├── middleware // リクエスト毎に差し込む処理をまとめたミドルウェア
├── util 
└── testutil

packageの切り方は以下を大変参考にさせていただいている。

上記のpackage以外に application/mockdomain/service/mockinfra/db/mock というmockを格納する用のpackageもあり、そこに各々のレイヤーのmock用のファイルを置いている。(詳しくは後述)

依存関係

依存関係としてはざっくり、interface/controllerapplicationdmain/repository or dmain/serviceinfra/db という形になっている。

参考: GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck

domain/~infra/db で矢印が逆になっているのは、依存関係が逆転しているため。
詳しくは その設計、変更に強いですか?単体テストできますか?...そしてクリーンアーキテクチャ - Qiitaを参照。

先ほどの矢印の中で、domain/model は記述しなかったが、 domain/model は、interface/controllerapplication 等からも依存されている。純粋なレイヤードアーキテクチャでは、各々のレイヤーは自分の下のレイヤーにのみ依存するといったものがあるかもしれないが、それを実現するためにDTO等を用意する必要があって、今回の実装ではそこまで必要はないかなと思ったためそうした。(厳格にやる場合は、実装した方がいいかもしれない)

各レイヤーでのinterfaceの定義とテスト

applicaiondomain/serviceinfra/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

ログイン周り

DB周り

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 を所持する
    • そうすることで SQLManagerTxManager (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 レイヤでは、deferCloseTransaction を呼び出す(ここでは 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
}
// 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 側( pagescomponents )からデータを使用したい場合には、Vuexを通じて使用した
    • データ、ロジックとビュー部分が綺麗に別れる

見た目

大きな流れ

大きな流れとしては、以下のような流れ。
pasgescomponents 等のビューでのイベントの発生 → actions 経由でAPIへリクエスト → mutationsstate 変更 → pasgescomponents 等のビューに反映される

他の流れもたくさんあるが、代表的なList処理とInput処理の流れを以下に記す。

List処理

  • pagescomponentsasyncData 内で、store.dispatch を通じて、データ一覧を取得するアクション( actions )を呼び出す
  • storeactions 内での処理を行う
    • axiosを使用してAPIにリクエストを送信する
    • APIから返却されたデータを引数に mutationscommit する。
  • mutations での処理を行う
    • state を変更する
  • pagescomponents のビューで取得したデータが表示される

Input処理

  • pagescomponentsstores に定義した actionstate を読み込んでおく
  • pagescomponentsdata 部分とformのinput部分等に v-model を使用して双方向データバインディングをしておく
  • pagescomponents で表示しているビュー部分でイベントが生じる
    • form入力→submitなど
  • sumitする時にクリックされるボタンに @click=hoge という形でイベントがそのElementで該当のイベントが生じた時に呼び出されるメソッド等を登録しておく
  • 呼び出されたメソッドの処理を行う
    • formのデータを元にデータを登録するアクション( actions )を呼び出す
  • storeactions 内での処理を行う
    • axiosを使用してAPIにリクエストを送信する
    • APIから返却されたデータを引数に mutationscommit する。
  • mutations での処理を行う
    • state を変更する
    • 登録した分のデータを一覧の state に追加する
  • pagescomponents のビューで登録したデータが追加された一覧表示される

非同期部分

所感

  • 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入門 基礎から実践アプリケーション開発まで』技術評論社

参考にさせていただいた記事

サーバーサイド

フロントエンド

関連記事

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

【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層のテストコードにおいては、実行時にテスト用のadapterusecaseにセットできるので容易に処理の切り替えが可能。
それに対して、実際のプロダクトコードは、そもそもmain.goから始まり、(当ケースで言うと)WebAPIとして機能を提供するので、WebアプリとしてHTTPリクエストを受け付けるロジックの実装が必要。
その中の特定のエンドポイントでHTTPリクエストを受け付けるロジックにて、上記usecase層のロジックを呼び出すことになる。
つまり、main.gousecase層ロジック呼び出しまでの間に、どのadapterを使用するかを何かの条件で決定する必要がある。

今回、プロダクトコードの実装では、main.gousecase層の呼び出し間に(パッケージ名で)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 &noticeImpl{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 [::]:8080

curlで動作確認

$ 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

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