14
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

速習 Go + Clean Architecture

Last updated at Posted at 2020-01-22

概要

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

あの図

CleanArchitecture.jpg
via. The Clean Architecture
なんとなく分かったような分からないようなだ。役割ごとにレイヤーを分けて、依存関係をコントロールしていく感じだろうか。参考になる記事や書籍は世の中にたくさんあるので、詳細はそれらを読んでいただきたい。

学習方針

  1. Hello World
  2. 1枚ペラで簡単なAPIを作成
  3. Architecture に沿うように順々にバラしていく
  4. つまったら、都度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.

main.go
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 などで立ち上げておいて欲しい。私は以下のようにした。

docker-compose のサンプル

最初の API

では、学習の元になる User API をペラ1で実装する。
ソースコードは記事が長くなるので、Github 参照。

ペラ 1 の main.go

こんなに簡単に書けるものを複雑にしていく必要は、このレベルであれば全くないと思うが、勉強のためと諦める。なお、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!)

domain を定義

なお、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 に書く。

usecase/service: service

Repository を作る

あの図の右下の部分。
スクリーンショット 2020-01-22 9.51.02.png

依存性逆転の法則(DIP: Dependency Inversion Principle)の適用で実現している。はず。理解度が浅く、うまく説明できない。ので更なる学習が必要。コードを追ってやっていくと、自分が何がよく理解できていないのかが明らかになる。

├── domain
├── infrastructure
│   ├── api
│   └── datastore
│       └── userRepository.go (NEW!)
├── interface
├── usecase
│   ├── repository
│   │   └── userRepository.go (NEW!)
│   └── service
│       └── userService.go (UPDATED!)
└── main.go (UPDATED!)

Repository

presenter も同様のことをやるが、User API では利用しない。

UML を見てみる

ここまでで、諸々作業は残っているものの、構造的には、いったん Clean Architecture になった。
UMLを見てみると、

スクリーンショット 2020-01-20 18.24.35.png

こんな感じになっている。

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!)

validation

チェック


$ 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 Connection

この時点では、まだ呼び出していない。PostgreSQL に MySQL にあった Users テーブルと同じ構造のものを作っておけば、main.goNewMySQL としているところを NewPostgreSQL とするだけで切り替えることができる。最終的なコードでは registry の中で i.dbi.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

Property API

API の追加は、以下のような感じ。main.go は API を追加するだけなら変更は不要。今回は PostgreSQL を追加しているので変更されている。

infrastructure/api/handler/appHandler.go
package handler

type AppHandler struct {
    UserHandler
+   PropertyHandler
}
registry/registry.go
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()}
}
...
main.go
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件取得の方はそのままにした。

interface/presenter

チェック

$ 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 を”東京都”などと表示したいところだが、今回は息切れ。

都道府県コードでの検索を実装する

忘れていたので、付け足した。

search by pref

チェック。

$ 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

スクリーンショット 2020-01-22 11.50.47.png

最後に全部チェックしてみる。

## 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万円"
  }
]

おしまい。

14
21
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
14
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?