お題
前回書いた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
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
」のセット有無で判定させる。アプリ起動時に判定後、判定結果はグローバル変数に持たせておく。↓
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関数を含むラッパーのソースは下記。
まあ、本当はコネクションプール数の設定など、もろもろ必要になるが、サンプルソースなので省略。
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)
■ルーティングとユースケース呼び出し
リクエストを受け付けるエンドポイントは以下にて定義。
package handler
import (
"net/http"
"github.com/labstack/echo"
)
const PersistenceKey = "PERSISTENCE"
func Routing(e *echo.Echo) {
http.Handle("/", e)
HandleNotice(e)
}
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
は下記のように、ローカル環境か否かで使用する構造体を切り替えている。
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
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 [::]: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