LoginSignup
0
1

More than 3 years have passed since last update.

【Golang:Ginkgo】BDDがちょうどいいかもしれない。(実践編2)

Posted at

お題

前回書いた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

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1