概要
Go 言語 + Clean Architecture を必要に迫られ勉強した。緊急だったので、Clean Architecture の概念をなめて、ネット上に散らばるソースコードを読んだ。が、ぶっちゃけよく分からなかったので、自分で書いてみた。3週くらいして、なんとなく分かった。気がする。
今回の学習のゴールは、チームのメンバー(同じく未経験)が理解できて、実装時に設計思想からはみ出ないようにすること。細かいことは抜きにして、読んでコピペすれば、何とかなる程度まで行ければ、細かい話はレビューで潰せば良い。という感じ。
Go も Clean Architecture も初めてな上、設計・実装からも遠ざかっていたので、理解に時間がかかった。完全に理解しているとは言い難いが、いちおうメモとして残しておく。実装は割と楽しかった。
作るもの
- go, clean architecture, net/http, gorm な REST API群
- 2 系統の CRUD API(User, Property)
- 2 系統の DB(MySQL, PostgreSQL)
最初、Gin で作ったがあまり必要性が感じられなかったので、net/http で作り直した。
ネット上の記事を読むと、go clean architecture な CRUD API の例はたくさん出てくる。が、1つ作って終わりのものが多い。Clean Architecture を理解するための記事なので、何個も作る必要はもちろんない。しかし、じゃあ実際に API を作るときに、1つだけの API なんてない訳で、どうやって2つ目の API 追加するの?とか、そういうのは、達人には自明でも、初心者は応用がきかない。ので、2つ作った。
また、DB は用途によって様々なものが用いられるので、2系統用意してみた。本当は、ユーザ情報のような少ないレコード数のものは、NoSQL、物件情報のような複雑で大量のレコード数のものは RDS というような使い分けが考えられるが(そもそも、そういうものを混ぜるなという話もあるが、規模次第)、Clean Architecture の学習からは外れるので、MySQL と PostgreSQL にした。正直あんまり意味はない。
最終的なソースコードは以下に置いてある。
https://github.com/kurab/learningGoCleanArchitecture
Clean Architecture とは
いちおう見ておく。
What is the Clean Architecture?
Clean architecture is a software design philosophy that separates the elements of a design into ring levels. The main rule of clean architecture is that code dependencies can only come from the outer levels inward. Code on the inner layers can have no knowledge of functions on the outer layers. The variables, functions and classes (any entities) that exist in the outer layers can not be mentioned in the more inward levels. It is recommended that data formats also stay separate between levels.
via. https://whatis.techtarget.com/definition/clean-architecture
あの図
via. The Clean Architecture
なんとなく分かったような分からないようなだ。役割ごとにレイヤーを分けて、依存関係をコントロールしていく感じだろうか。参考になる記事や書籍は世の中にたくさんあるので、詳細はそれらを読んでいただきたい。
学習方針
- Hello World
- 1枚ペラで簡単なAPIを作成
- Architecture に沿うように順々にバラしていく
- つまったら、都度1枚ペラで試してみる
最終的に Clean になれば、途中はいったん Clean じゃなくても気にしない。
API の仕様
USER API
- 全件取得
- 1件取得
- 登録
これはあちこちの解説で例として利用されている定番で、定番ゆえ迷った時に色々な記事と比較できるように、用意する。
不動産物件情報取得 API
- 全件取得
- 1件取得
- 条件マッチ取得
検索サービスにおいては、検索条件が1つだけなんてことはなく、様々な条件が組み合わさる。東京都、中野区で駅徒歩10分以内の賃料7.5万円以下25m2以上の賃貸マンションとか。としたいところだが、条件があったりなかったりをゴニョゴニョするのは、今回の趣旨とは若干離れるため、簡単に公開・非公開フラグと都道府県の組み合わせで検索をする。
また、不動産情報のようなものは、構造化されていて都道府県はコードで入っていたり、面積や価格などは単位のない数字として入っていることが多く、そのままでは使えないため、使いやすいように加工して返却する。
各 API の決め事
説明簡略化も含め、どのAPIも
- Create: /api/(api name like user)/register
- 条件付きRead: /api/(api name like user)/get/:id
- 全件取得Read: /api/(api name like user)/get
などとし、Update と Delete は今回は省略する。
では、早速。
Hello, World.
package main
import (
"fmt"
"net/http"
"github.com/julienschmidt/httprouter"
)
func setRouter() *httprouter.Router {
router := httprouter.New()
router.GET("/", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
fmt.Fprintf(w, "Hello, world!")
})
return router
}
func main() {
r := setRouter()
http.ListenAndServe(":8080", r)
}
チェック
$ go get github.com/julienschmidt/httprouter
$ go run main.go
$ curl -D - 0.0.0.0:8080
TTP/1.1 200 OK
Date: Sun, 19 Jan 2020 04:20:06 GMT
Content-Length: 13
Content-Type: text/plain; charset=utf-8
Hello, world!
OK. 割と簡単なので、何とかなりそうな気がしてきた。Thank you, world!
User API
User は、Validation や暗号化をやりたいので、複雑になりすぎない程度で、id, name, age, email のフィールドを持たせる。暗号化は今回はやらないが気が向いたら追加する。
MySQLは、Docker などで立ち上げておいて欲しい。私は以下のようにした。
最初の API
では、学習の元になる User API をペラ1で実装する。
ソースコードは記事が長くなるので、Github 参照。
こんなに簡単に書けるものを複雑にしていく必要は、このレベルであれば全くないと思うが、勉強のためと諦める。なお、Gin を使った場合、router 処理の長ったらしい引数や JSON の扱いがシンプルになる。(浅い!)
チェック
$ curl 0.0.0.0:8080/api/user/register curl -X POST -H "Content-Type: application/json" -d '{"name":"hiroshi", "age":43, "email":"hiroshi@foo.bar"}'
$ curl 0.0.0.0:8080/api/user/register curl -X POST -H "Content-Type: application/json" -d '{"name":"john doe", "age":23, "email":"jonh@foo.bar"}'
$ curl 0.0.0.0:8080/api/user/register curl -X POST -H "Content-Type: application/json" -d '{"name":"Alice", "age":18, "email":"alice@foo.bar"}'
$ curl 0.0.0.0:8080/api/user/get | jq
$ curl 0.0.0.0:8080/api/user/get/3 | jq
諸々オッケー。以降、変化がない限りチェックは飛ばすが、都度やっている。
このペラ1を以下のような構造にバラしていく。
├── config
├── domain //Enterprise Business Rules (Entities)
│ └── model
│ └── Users.go
├── infrastructure //framework, Drivers (External Interfaces)
│ ├── api
│ │ ├── handler
│ │ ├── middleware
│ │ ├── router
│ │ └── validater
│ └── datastore
├── interface //Interface Adapters (Controller, Gateway, Presenter)
│ ├── controllers
│ └── presenters
├── registry
├── usecase //Application Business Rules (Use Case)
│ ├── presenter
│ ├── repository
│ └── service
└── main.go
Domain
このレイヤーでは、domain を表すのに必要なデータとメソッドを定義する。あの図の真ん中の黄色い部分。
どこにも依存しない。
├── app
├── domain
│ └── model
│ └── user.go (NEW!)
└── main.go (UPDATED!)
なお、go module を利用しており、終始
$ go mod init api
$ go build
$ go run api
などとしている。コード中の実装した package の import は、github のパスにはなっていない。
Infrastructure
外と中をつなぐレイヤーで、Interface Adapter に依存する。あの図の一番外の青い輪っか。
DB Connection
├── domain
├── infrastructure
│ └── datastore
│ └── dbMySQL.go (NEW!)
└── main.go (UPDATED!)
▶ infrastructure/datastore: db connection
DBに接続する部分を infrastructure に切り出した。
切り出して main.go から呼んでいるだけ。
Router
├── domain
├── infrastructure
│ ├── api
│ │ └── router
│ │ └── router.go (NEW!)
│ └── datastore
└── main.go (UPDATED!)
▶ infrastructure/api/router: router
同様にして Routing の処理を切り出す。
Handler
├── domain
├── infrastructure
│ └── api
│ ├── handler
│ │ └── userHandler.go (NEW!)
│ └── router
│ └── router.go (UPDATED!)
└── main.go (UPDATED!)
▶ infrastructure/api/handler: handler
Router は Routing だけに集中してもらい、中身の処理を handler に移した。
ここで、この後も散々出てくるが、以下のような interface, struct, NewXX を用いることで、UserHandler を利用する側が、UserHandler の Interface だけを知っている状態(内部実装は知らなくて良い)とする。こんな感じで依存関係を考えていく。 Dependency Injection Pattern を調べると良い。
type UserHandler interface {
CreateUser(...)
GetAllUsers(...)
GetUser(...)
}
type userHandler struct {
db *gorm.DB
}
func NewUserHandler(db *gorm.DB) UserHandler {
return &userHandler{db: db}
}
Interface Adapter
Interface Adapter は、Infrastructure と Usecase をつなぐレイヤーで、
Controller では外から受け取った値を内部的に使いやすいようにする。
Presenter では、内部から受け取ったデータを外で使いやすいようにする。
Usecase に依存する。真ん中の緑の輪っかの部分。
controller
├── domain
├── infrastructure
│ ├── api
│ │ ├── handler
│ │ │ └── userHandler.go (UPDATED!)
│ │ └── router
│ └── datastore
├── interface
│ └── controllers
│ └── userController.go (NEW!)
└── main.go (UPDATED!)
handler の処理を、controller, usecase にばらしていく。まずは、controller
▶ interface/controllers: controllers
Presenter は USER API では実装しない。
Usecase
ビジネスロジックを定義するレイヤー。Domain に依存する。
ピンクの部分。
Service
├── domain
├── infrastructure
├── interface
│ └── controllers
│ └── userController.go (UPDATED!)
├── usecase
│ └── service
│ └── userService.go (NEW!)
└── main.go (UPDATED)
controller には、技術的に何がしたいのかだけが書かれているイメージ。具体的なビジネスロジックを usecase に書く。
Repository を作る
依存性逆転の法則(DIP: Dependency Inversion Principle)の適用で実現している。はず。理解度が浅く、うまく説明できない。ので更なる学習が必要。コードを追ってやっていくと、自分が何がよく理解できていないのかが明らかになる。
├── domain
├── infrastructure
│ ├── api
│ └── datastore
│ └── userRepository.go (NEW!)
├── interface
├── usecase
│ ├── repository
│ │ └── userRepository.go (NEW!)
│ └── service
│ └── userService.go (UPDATED!)
└── main.go (UPDATED!)
presenter も同様のことをやるが、User API では利用しない。
UML を見てみる
ここまでで、諸々作業は残っているものの、構造的には、いったん Clean Architecture になった。
UMLを見てみると、
こんな感じになっている。
Registry
main.go で、依存関係をズラズラ書いてきたが、今後もどんどん増えるので、切り離す。
├── domain
├── infrastructure
├── interface
├── registry
│ └── registry.go (NEW!)
├── usecase
└── main.go (UPDATED!)
▶ registry
これはこれで分かりやすいが、長くなるので気に入らない。いずれ考える。
Validation
Validation は、infrastructure レイヤーでやる。
github.com/go-playground/validator/v10
を使った。
├── config
├── domain
│ └── model
│ └── user.go (UPDATED!)
├── infrastructure
│ ├── api
│ │ ├── handler
│ │ │ └── userHandler.go (UPDATED!)
│ │ ├── router
│ │ └── validator
│ │ └── validater.go (NEW!)
│ └── datastore
├── interface
├── registry
│ └── registry.go (UPDATED!)
├── usecase
└── main.go (UPDATED!)
チェック
$ curl 0.0.0.0:8080/api/user/register curl -X POST -H "Content-Type: application/json" -d '{"name":"", "age":300, "email":"@foobar"}'
Validation error: Key: 'User.Name' Error:Field validation for 'Name' failed on the 'required' tag
Key: 'User.Age' Error:Field validation for 'Age' failed on the 'lt' tag
Key: 'User.Email' Error:Field validation for 'Email' failed on the 'email' tag
ちゃんと Validation できてる。
Config
dbMySQL.go に接続情報がベタ書きなので、設定ファイルに切り出す。環境変数に格納することも多いと思うので、それにも対応しておく。
├── config
│ ├── config.go (NEW!)
│ └── config.yml (NEW!)
├── domain
├── infrastructure
│ ├── api
│ └── datastore
│ └── dbMySQL.go (UPDATED!)
├── interface
├── registry
├── usecase
└── main.go (UPDATED!)
▶ config
やっと、それっぽい User API が完成した。
Property API
DB は User API で利用した MySQL ではなく、PostgreSQL を利用する。最初に提示した docker-compose に入れてある。
Properties テーブルは、
item | type | description |
---|---|---|
id | int | 物件ID |
flg_open | bool | 公開・非公開フラグ |
pref_cd | smallint | 都道府県コード |
walk_time | int | 駅徒歩分 |
area | numeric(10,2) | 面積 |
price | int | 価格 |
とし、予め以下のデータを入れておく。
id | flg_open | pref_cd | walk_time | area | price |
---|---|---|---|---|---|
1 | ture | 13 | 10 | 70.50 | 100000000 |
2 | true | 14 | 15 | 100.00 | 50000000 |
3 | false | 13 | 5 | 50.15 | 60000000 |
4 | true | 13 | 7 | 80.00 | 120000000 |
API を Call した際に、Response では、駅徒歩分には”分”、面積には"m2"を付け、価格は、10,000で割って”万円”を付けて返却する。
walk_time, area, price は DB 上は整数・実数で格納されているが、API 上は文字列として扱いたいので、以下で示す model では string
としている。ただし、これはビジネス・ロジックのような気がするので、Domain に入れるべきではないかも知れない。
検索結果は、1件取得を除き、指定条件(今回はpref_cdまたはなし)+公開フラグが立っている(flg_open=ture)ものを都道府県コードの昇順、価格の昇順(安い)もので並べる。
都道府県コードが13番で検索した場合は、1, 4, 2 の順で検索されて 3 は検索されない。というものを作る。
PostgreSQL Connection
Gorm で PostgreSQL にも接続できるようにする。
├── config
│ ├── config.go (UPDATED!)
│ └── config.yml (UPDATED!)
├── domain
│ └── model
│ ├── property.go (NEW!)
│ └── user.go
├── infrastructure
│ ├── api
│ └── datastore
│ ├── dbMySQL.go
│ ├── dbPostgreSQL.go (NEW!)
│ └── userRepository.go
├── interface
├── registry
├── usecase
└── main.go (UPDATED!)
この時点では、まだ呼び出していない。PostgreSQL に MySQL にあった Users テーブルと同じ構造のものを作っておけば、main.go
で NewMySQL
としているところを NewPostgreSQL
とするだけで切り替えることができる。最終的なコードでは registry の中で i.db
を i.ps
とするだけ。DB の接続部分だけ作れば、後はプログラム的には MySQL だろうが PostgreSQL だろうがどうでも良い。
Property API を実装する
大体コピペで行ける。コピペの勢いが付きすぎて、都道府県コードによる条件検索を忘れた。最後につける。
├── config
├── domain
├── infrastructure
│ ├── api
│ │ ├── handler
│ │ │ ├── appHandler.go (UPDATED!)
│ │ │ ├── propertyHandler.go (NEW!)
│ │ │ └── userHandler.go
│ │ ├── router
│ │ │ └── router.go (UPDATED!)
│ │ └── validator
│ └── datastore
│ ├── dbMySQL.go
│ ├── dbPostgreSQL.go
│ ├── propertyRepository.go (NEW!)
│ └── userRepository.go
├── interface
│ └── controllers
│ ├── propertyController.go (NEW!)
│ └── userController.go
├── registry
│ ├── registry.go (UPDATED!)
├── usecase
│ ├── repository
│ │ ├── propertyRepository.go (NEW!)
│ │ └── userRepository.go
│ └── service
│ ├── propertyService.go (NEW!)
│ └── userService.go
└── main.go
API の追加は、以下のような感じ。main.go は API を追加するだけなら変更は不要。今回は PostgreSQL を追加しているので変更されている。
package handler
type AppHandler struct {
UserHandler
+ PropertyHandler
}
package registry
...
type Interactor interface {
NewAppHandler() handler.AppHandler
}
func NewInteractor(db *gorm.DB, ps *gorm.DB, v *validator.Validate) Interactor {
return &interactor{db: db, postgres: ps, validator: v}
}
func (i *interactor) NewAppHandler() handler.AppHandler {
return handler.AppHandler{
UserHandler: i.NewUserHandler(),
PropertyHandler: i.NewPropertyHandler()}
}
...
package main
...
func main() {
...
h := rg.NewAppHandler()
...
}
では、チェック
$ curl 0.0.0.0:8080/api/property/get | jq
[
{
"id": 1,
"flg_open": true,
"pref_cd": 13,
"walk_time": "10",
"area": "70.50",
"price": "100000000"
},
{
"id": 4,
"flg_open": true,
"pref_cd": 13,
"walk_time": "7",
"area": "80.00",
"price": "120000000"
},
{
"id": 2,
"flg_open": true,
"pref_cd": 14,
"walk_time": "15",
"area": "100.00",
"price": "50000000"
}
]
$ curl 0.0.0.0:8080/api/property/get/3 | jq
{
"id": 3,
"flg_open": false,
"pref_cd": 13,
"walk_time": "5",
"area": "50.15",
"price": "60000000"
}
思ったように並んでいる。
Presenter で加工する
単位を付けたり円を万円にしたりする。
├── config
├── domain
├── infrastructure
├── interface
│ ├── controllers
│ └── presenters
│ └── propertyPresenter.go (NEW!)
├── registry
│ ├── registry.go (UPDATED!)
├── usecase
│ ├── presenter
│ │ └── propertyPresenter.go (NEW!)
│ ├── repository
│ │ ├── propertyRepository.go
│ │ └── userRepository.go
│ └── service
│ ├── propertyService.go (UPDATED!)
│ └── userService.go
└── main.go
違いを見るために、全件取得の方だけ加工し、1件取得の方はそのままにした。
チェック
$ curl 0.0.0.0:8080/api/property/get | jq
[
{
"id": 1,
"flg_open": true,
"pref_cd": 13,
"walk_time": "10分",
"area": "70.50m2",
"price": "10000万円"
},
{
"id": 4,
"flg_open": true,
"pref_cd": 13,
"walk_time": "7分",
"area": "80.00m2",
"price": "12000万円"
},
{
"id": 2,
"flg_open": true,
"pref_cd": 14,
"walk_time": "15分",
"area": "100.00m2",
"price": "5000万円"
}
]
$ curl 0.0.0.0:8080/api/property/get/3 | jq
{
"id": 3,
"flg_open": false,
"pref_cd": 13,
"walk_time": "5",
"area": "50.15",
"price": "60000000"
}
できました。本来であれば、pref_cd = 13 を”東京都”などと表示したいところだが、今回は息切れ。
都道府県コードでの検索を実装する
忘れていたので、付け足した。
チェック。
$ curl 0.0.0.0:8080/api/property/pref/get/13 | jq
[
{
"id": 1,
"flg_open": true,
"pref_cd": 13,
"walk_time": "10分",
"area": "70.50m2",
"price": "10000万円"
},
{
"id": 4,
"flg_open": true,
"pref_cd": 13,
"walk_time": "7分",
"area": "80.00m2",
"price": "12000万円"
}
]
OYAY!
最終的な UML
最後に全部チェックしてみる。
## USER 登録
$ curl 0.0.0.0:8080/api/user/register curl -X POST -H "Content-Type: application/json" -d '{"name":"Prakruti", "age":20, "email":"prakruti@foo.bar"}'
registerd
## USER 登録 Validation error
$ curl 0.0.0.0:8080/api/user/register curl -X POST -H "Content-Type: application/json" -d '{"name":"", "age":300, "email":"foobar"}'
Validation error: Key: 'User.Name' Error:Field validation for 'Name' failed on the 'required' tag
Key: 'User.Age' Error:Field validation for 'Age' failed on the 'lt' tag
Key: 'User.Email' Error:Field validation for 'Email' failed on the 'email' tag
## USER 全件取得
$ curl 0.0.0.0:8080/api/user/get | jq
[
{
"id": 1,
"name": "hiroshi",
"age": 43,
"email": "hiroshi@foo.bar"
},
{
"id": 2,
"name": "john doe",
"age": 23,
"email": "jonh@foo.bar"
},
{
"id": 3,
"name": "Alice",
"age": 18,
"email": "alice@foo.bar"
},
{
"id": 4,
"name": "robert",
"age": 30,
"email": "robert@foo.bar"
},
{
"id": 5,
"name": "Mary",
"age": 21,
"email": "mary@foo.bar"
},
{
"id": 6,
"name": "",
"age": 43,
"email": "hiroshi@foo.bar"
},
{
"id": 7,
"name": "Maricruz",
"age": 30,
"email": "maricruz@foo.bar"
},
{
"id": 8,
"name": "Prakruti",
"age": 20,
"email": "prakruti@foo.bar"
}
]
## USER 1件取得
$ curl 0.0.0.0:8080/api/user/get/3 | jq
{
"id": 3,
"name": "Alice",
"age": 18,
"email": "alice@foo.bar"
## PROPERTY 全件取得
$ curl 0.0.0.0:8080/api/property/get | jq
[
{
"id": 1,
"flg_open": true,
"pref_cd": 13,
"walk_time": "10分",
"area": "70.50m2",
"price": "10000万円"
},
{
"id": 4,
"flg_open": true,
"pref_cd": 13,
"walk_time": "7分",
"area": "80.00m2",
"price": "12000万円"
},
{
"id": 2,
"flg_open": true,
"pref_cd": 14,
"walk_time": "15分",
"area": "100.00m2",
"price": "5000万円"
}
]
## PROPERTY 1件取得
$ curl 0.0.0.0:8080/api/property/get/3 | jq
{
"id": 3,
"flg_open": false,
"pref_cd": 13,
"walk_time": "5",
"area": "50.15",
"price": "60000000"
}
## PROPERTY 条件マッチ取得
$ curl 0.0.0.0:8080/api/property/pref/get/13 | jq
[
{
"id": 1,
"flg_open": true,
"pref_cd": 13,
"walk_time": "10分",
"area": "70.50m2",
"price": "10000万円"
},
{
"id": 4,
"flg_open": true,
"pref_cd": 13,
"walk_time": "7分",
"area": "80.00m2",
"price": "12000万円"
}
]
おしまい。