お題
前回はGAE/Go(v1.11)の"google.golang.org/appengine"パッケージを使って情報取得というのをやった。
今回、当初はクリーンアーキテクチャ導入にトライしてみようかと考えたものの、ディレクトリ構成を用意していくだけで大分しんどいことに気づいた。
他の記事を読んでいるだけでは特に何も感じなかったけど、実際に自分で試そうとした時、scaffoldが無いとあまりにキツい。
そして、慣れるまではどこに何があるのかわからず、コードを追うのも辛かった。
というわけで、まずは慣れ親しんだMVC+Sくらいの構成から作ってみようと思った。
GAE試行Index
- GAE/Go1.11試行(その2:「"google.golang.org/appengine"パッケージのappengineから取れる情報」)
- GAE/Go1.11試行(その1:「クイックスタート」)
- GAE/Go1.9試行(その0:「クイックスタート」)
開発環境
# OS
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="17.10 (Artful Aardvark)"
# Golang
$ go version
go version go1.11.2 linux/amd64
バージョンの切り替えはgoenvで行っている。
前提
- MVCの何たるかはなんとなくでも知っている。
実践
プロジェクト構成
$ tree
.
├── app.yaml
├── controller
│ ├── apierror.go
│ ├── form
│ │ ├── form.go
│ │ ├── id.go
│ │ └── user.go
│ └── user.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── index.yaml
├── main.go
├── model
│ ├── dto.go
│ └── user.go
├── service
│ └── user.go
└── view
説明
今回想定したのは、「ユーザ」情報を登録・取得・更新・削除するWebAPI。永続化はMySQL。
各エンドポイントは以下。
機能 | HTTPメソッド | パス |
---|---|---|
登録 | POST | /api/v1/users |
取得 | GET | /api/v1/users/【ユーザID】 |
更新 | UPDATE | /api/v1/users/【ユーザID】 |
削除 | DELETE | /api/v1/users/【ユーザID】 |
■ main.go
1 func main() {
2 db, err := gorm.Open("mysql", "testuser:testpass@tcp(127.0.0.1:3306)/testdb?charset=utf8&parseTime=True&loc=Local")
3 if err != nil {
4 panic(err)
5 }
6 defer db.Close()
7
8 db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8").AutoMigrate(&model.User{})
9
10 e := echo.New()
11 defer e.Close()
12
13 http.Handle("/", e)
14 g := e.Group("/api/v1")
15
16 controller.NewUser(db).Handle(g)
17
18 appengine.Main()
19 }
まず、2行目でMySQLに接続。
この接続情報は、あくまでローカル(docker-composeで立ち上げるMySQLへの接続限定)用。
GAE環境にデプロイする時は、接続情報はもとより、形式も下記のように変える必要がある。
"【ユーザ】:【パスワード】@unix(/cloudsql/【インスタンス接続文字列(※おそらく「【プロジェクト名】:【リージョン】:【インスタンスID】」)】)/【DB名】"
ちなみに、 docker-compose.yml
の中身は下記。
version: '3'
services:
db:
image: mysql:5.7.24
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: testuser
MYSQL_PASSWORD: testpass
MYSQL_DATABASE: testdb
8行目でGoの構造体をベースにMySQL上にテーブルを自動生成。
16行目で今回WebAPIとして提供する「ユーザ」に関するコントローラー(MVC+Sで言うCの部分)をHTTPリクエストの処理ハンドラーとして登録。
18行目でWebサーバ起動。 appengine.Main()
を使っているけど、GAEでのDatastoreを使おうとしなければ、おそらく直接 http.ListenAndServe(〜〜)
を呼んでも大丈夫。
■ controller/user.go
main.go
の16行目で「 controller.NewUser(db).Handle(g)
」という形で呼び出されている。
【User
の生成部分】
func NewUser(db *gorm.DB) User {
return &user{db: db}
}
type user struct {
db *gorm.DB
}
type User interface {
Handle(g *echo.Group)
}
【Handle】
func (u *user) Handle(g *echo.Group) {
g.POST("/users", u.createUser)
g.GET("/users/:id", u.getUser)
g.PUT("/users/:id", u.updateUser)
g.DELETE("/users/:id", u.deleteUser)
}
それぞれのパスに対して、対応する処理メソッドを登録している。
以下、登録のメソッドをピックアップ。
1 func (u *user) createUser(c echo.Context) error {
2 uf := &form.User{}
3 if err := c.Bind(uf); err != nil {
4 return c.JSON(http.StatusBadRequest, errorJSON(http.StatusBadRequest, err.Error()))
5 }
6
7 id, err := service.NewUser(u.db).CreateUser(uf.ParseToDto())
8 if err != nil {
9 return c.JSON(http.StatusBadRequest, errorJSON(http.StatusBadRequest, err.Error()))
10 }
11
12 return c.JSON(http.StatusCreated, &form.ID{ID: id})
13 }
3行目でリクエストパラメータを構造体 form.User
にパースしている。
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
7行目で「 service.NewUser(u.db).CreateUser(uf.ParseToDto())
」としてサービス(MVC+Sで言うSの部分)を使ってリクエストを処理。
「 uf.ParseToDto()
」の実装は下記の通り。
func (n *User) ParseToDto() *model.User {
// フォーマット変換等は、ここで吸収
return &model.User{
ID: n.ID,
Name: n.Name,
}
}
リクエストパラメータをパースしたままの構造でDB登録まで受け渡してもいいのだけど、例えば日付のような項目をJSONで文字列の「2018/12/08」として受け取って、DB登録時はTimestampに変換して登録したいとかの都合に耐える設計にするために用意。
また、このように form と model(のDTO) とを分けることで、JSONパラメータとのマッピング用のタグとDBの登録時のカラムとのマッピング(や主キー、Not Null等の制約付け)用のタグとが混在して煩雑になるのを防げる。
■ service/user.go
ここは、イメージとしてはデザインパターンのFacadeに当たる。複数のモデル(=ビジネスロジック)を扱い、1つのユースケースを実現する。
トランザクション境界としての役割も担う。
とはいえ、今回のように「ユーザ」の情報を扱うだけなら、むしろ不要な”層”と言える。
func (n *userService) CreateUser(m *model.User) (string, error) {
return model.NewUserDao(n.db).CreateUser(m)
}
■ model/user.go
リクエストパラメータをパースした構造(=form/user.go
)から変換されたのが↓
type User struct {
ID string `gorm:"column:id;primary_key"`
Name string `gorm:"column:name;type:varchar(255);not null"`
}
いわゆる DataAccessObject として振る舞う構造体の生成に関する実装は下記。
func NewUserDao(db *gorm.DB) UserDao {
return &userDao{db: db}
}
type userDao struct {
db *gorm.DB
}
type UserDao interface {
CreateUser(m *User) (string, error)
GetUser(id string) (*User, error)
UpdateUser(m *User) (*User, error)
DeleteUser(id string) error
}
で、登録に関するメソッドが下記。
func (n *userDao) CreateUser(m *User) (string, error) {
m.ID = strings.Replace(uuid.New().String(), "-", "", -1)
n.db.Create(&m)
if n.db.Error != nil {
return "", n.db.Error
}
return m.ID, nil
}
動作確認
Postmanを使って確認。
登録
こんな curl を叩いて、
curl -X POST \
http://localhost:8080/api/v1/users \
-H 'Cache-Control: no-cache' \
-H 'Content-Type: application/json' \
-H 'Postman-Token: fb182b6d-1ee3-4e1e-91ad-1c8b426e7dba' \
-d '{
"name": "さとう"
}'
こんなJSONが返る。
{
"id": "7f8a46635aad48ccb668d8f1e6c0751b"
}
取得
こんな curl を叩いて、
curl -X GET \
http://localhost:8080/api/v1/users/7f8a46635aad48ccb668d8f1e6c0751b \
-H 'Cache-Control: no-cache' \
-H 'Content-Type: application/json' \
-H 'Postman-Token: 0e933df9-5c71-4170-b888-7f4b1c368b51'
こんなJSONが返る。
{
"ID": "7f8a46635aad48ccb668d8f1e6c0751b",
"Name": "さとう"
}
まとめ
テストコードを書いていないのと、やはり1機能のCRUDレベルではクリーンアーキテクチャと比べたときの効用は計り知れない。
ただ、チームメンバーが誰も実際にクリーンアーキテクチャで実装したことがない、かつ、プロジェクトとしてそこに時間を費やせない場合、とにかくスピーディーに、かつ、ある程度、機能毎に分業できることを考えると、いい構成かも。