【Go言語】クリーンアーキテクチャで作るREST API
IT技術
【Go言語(Golang)】クリーンアーキテクチャで作るREST API
以前に投稿された以下の笹川先生の実装をクリーンアーキテクチャで作り替えてみよう!という内容です。
クリーンアーキテクチャとは?
参考:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
クリーンアーキテクチャで調べるとよく出てくる例の円ですね。
ざっくり説明すると、円の中の各項目は矢印の向き(内側)に依存(参照)するようにして、特定の項目の仕様変更が他の項目に影響を及ぼさないようにしています。
それによってそれぞれが外部の項目を気にすることなくテストできたり、仕様変更の影響範囲が少なくなったりといった利点があります。
実装:クリーンアーキテクチャ化
では早速、クリーンアーキテクチャ化を進めていきましょう!
Entities
外側は内側の参照を持つため、内側から順に実装していきます。
1package entities
2
3type User struct {
4 Name string `json:"name"`
5 Age int `json:"age"`
6 Address string `json:"address"`
7}
こちらはそのままUserの構造体を入れているだけですね。
笹川先生のものと同じく、JSONでの受け取りからJSONでの返却まで、このUser構造体でやりとりするようにします。
Use Cases
UseCasesではFirestoreだったりecho(Http通信)だったりの技術的な要素を省いた、ビジネスルールを記載していきます。
具体的には、今回作成するaddUserの実装は以下のようになっているので、
- echoでユーザーデータ(JSON)を受け取り
- Firestoreにユーザーデータを追加
- echoで全てのユーザーデータ(JSON)を返す or エラーを返す
技術的要素を省いて、
- ユーザーデータを受け取り
- 何かしらにユーザーデータを追加
- 全てのユーザーデータを返却する or エラーを返す
といった感じに実装していきます。
また、addUserで全てのユーザーを返却するついでに、全ユーザー返却単品のものも追加してあります。
1package ports
2
3import (
4 "context"
5 "firestore_clean/entities"
6)
7
8// 1. ユーザーデータを受け取り
9type UserInputPort interface {
10 AddUser(ctx context.Context, user *entities.User) error
11 GetUsers(ctx context.Context) error
12}
13
14// 3. 全てのユーザーを返却 or エラーを返す
15type UserOutputPort interface {
16 OutputUsers([]*entities.User) error
17 OutputError(error) error
18}
19
20// 2. 何かしらにユーザーデータを追加、全ユーザーを返す
21type UserRepository interface {
22 AddUser(ctx context.Context, user *entities.User) ([]*entities.User, error)
23 GetUsers(ctx context.Context) ([]*entities.User, error)
24}
Portではinterfaceのみを記述し、実体は別のgoファイルで実装します。
次は、同じくUseCasesのInteractorです。
1package interactors
2
3import (
4 "context"
5 "firestore_clean/entities"
6 "firestore_clean/usecases/ports"
7)
8
9type UserInteractor struct {
10 OutputPort ports.UserOutputPort
11 Repository ports.UserRepository
12}
13
14func NewUserInputPort(outputPort ports.UserOutputPort, repository ports.UserRepository) ports.UserInputPort {
15 return &UserInteractor{
16 OutputPort: outputPort,
17 Repository: repository,
18 }
19}
20
21func (u *UserInteractor) AddUser(ctx context.Context, user *entities.User) error {
22 users, err := u.Repository.AddUser(ctx, user)
23 if err != nil {
24 return u.OutputPort.OutputError(err)
25 }
26
27 return u.OutputPort.OutputUsers(users)
28}
29
30func (u *UserInteractor) GetUsers(ctx context.Context) error {
31 users, err := u.Repository.GetUsers(ctx)
32 if err != nil {
33 return u.OutputPort.OutputError(err)
34 }
35
36 return u.OutputPort.OutputUsers(users)
37}
Interactorでは、ユーザーデータを受け取るInputPortの実体と、先ほどの技術的要素を省いた1~3の手順を実装します。
技術的要素を省いた箇所についてはinterfaceを呼び出すことよって、UseCasesの外側にある技術的要素を隠蔽していますね。
これを依存性逆転の原則といい、円の外側への依存を回避した上で、コード変更の影響を最小限にすることができます。
Presenters
Presentersでは、OutputPortの実体を実装します。
また、ここからは技術的要素を含んでの実装になるので、echoやFirestore関連の実装も行なっていきます。
1package presenters
2
3import (
4 "firestore_clean/entities"
5 "firestore_clean/usecases/ports"
6 "log"
7 "net/http"
8
9 "github.com/labstack/echo/v4"
10)
11
12type UserPresenter struct {
13 ctx echo.Context
14}
15
16func NewUserOutputPort(ctx echo.Context) ports.UserOutputPort {
17 return &UserPresenter{
18 ctx: ctx,
19 }
20}
21
22func (u *UserPresenter) OutputUsers(users []*entities.User) error {
23 return u.ctx.JSON(http.StatusOK, users)
24}
25
26func (u *UserPresenter) OutputError(err error) error {
27 log.Fatal(err)
28 return u.ctx.JSON(http.StatusInternalServerError, err)
29}
出力自体は、echoが行ってくれるので、echoのPOSTメソッド等でreturnする値を定義しています。
今回実装するREST APIでは、「全てのユーザーを返す」or 「エラーを返す」しかないので、この2パターンのみ実装していますね。
Gateways
Gatewaysでは、DB操作の実体を実装します。
なので、Firestore関連の実装をする感じになりますね。
1package gateways
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "firestore_clean/entities"
8 "firestore_clean/usecases/ports"
9 "fmt"
10
11 "cloud.google.com/go/firestore"
12)
13
14type FirestoreClientFactory interface {
15 NewClient(ctx context.Context) (*firestore.Client, error)
16}
17
18type UserGateway struct {
19 clientFactory FirestoreClientFactory
20}
21
22func NewUserRepository(clientFactory FirestoreClientFactory) ports.UserRepository {
23 return &UserGateway{
24 clientFactory: clientFactory,
25 }
26}
27
28func (gateway *UserGateway) AddUser(ctx context.Context, user *entities.User) ([]*entities.User, error) {
29 if user == nil {
30 return nil, errors.New("user is nil")
31 }
32
33 client, err := gateway.clientFactory.NewClient(ctx)
34 if err != nil {
35 return nil, fmt.Errorf("failed AddUser NewClient: %v", err)
36 }
37 defer client.Close()
38
39 _, err = client.Collection("users").Doc(user.Name).Set(ctx, map[string]interface{}{
40 "age": user.Age,
41 "address": user.Address,
42 })
43
44 if err != nil {
45 return nil, fmt.Errorf("failed AddUser Set: %v", err)
46 }
47
48 return getUsers(ctx, client)
49}
50
51func (gateway *UserGateway) GetUsers(ctx context.Context) ([]*entities.User, error) {
52 client, err := gateway.clientFactory.NewClient(ctx)
53 if err != nil {
54 return nil, fmt.Errorf("failed GetUsers NewClient: %v", err)
55 }
56 defer client.Close()
57
58 return getUsers(ctx, client)
59}
60
61func getUsers(ctx context.Context, client *firestore.Client) ([]*entities.User, error) {
62 allData := client.Collection("users").Documents(ctx)
63
64 docs, err := allData.GetAll()
65 if err != nil {
66 return nil, fmt.Errorf("failed GetUsers GetAll: %v", err)
67 }
68
69 users := make([]*entities.User, 0)
70 for _, doc := range docs {
71 u := new(entities.User)
72 err = mapToStruct(doc.Data(), &u)
73 if err != nil {
74 return nil, fmt.Errorf("failed GetUsers mapToStruct: %v", err)
75 }
76 u.Name = doc.Ref.ID
77 users = append(users, u)
78 }
79
80 return users, nil
81}
82
83// map -> 構造体の変換
84func mapToStruct(m map[string]interface{}, val interface{}) error {
85 tmp, err := json.Marshal(m)
86 if err != nil {
87 return err
88 }
89 err = json.Unmarshal(tmp, val)
90 if err != nil {
91 return err
92 }
93 return nil
94}
ここはほとんど、笹川先生が実装したものと同じですね。
少し違う点としては、NewUserRepositoryでFirestoreのClientの初期化用interfaceを要求することで、AddUserとGetUsersそれぞれでFirestoreClientの初期化とClose処理を行うようにしています。
これで本番環境と開発環境を外部から変更することが可能になりますね。
また、errorを返却する際にはfmt.Errorf でerror文を加工し、PresentersのOutputErrorに渡した際にそのerrorを表示するようにすることで、出力関連の処理を全てPresentersに任せるようにしています。
Controllers
Controllersでは、InputPort・OutputPort・Repositoryを組み立てて、InputPortを実行します。
1package controllers
2
3import (
4 "context"
5 "firestore_clean/adapters/gateways"
6 "firestore_clean/entities"
7 "firestore_clean/usecases/ports"
8
9 "github.com/labstack/echo/v4"
10)
11
12type User interface {
13 AddUser(ctx context.Context) func(c echo.Context) error
14 GetUsers(ctx context.Context) func(c echo.Context) error
15}
16
17type OutputFactory func(echo.Context) ports.UserOutputPort
18type InputFactory func(ports.UserOutputPort, ports.UserRepository) ports.UserInputPort
19type RepositoryFactory func(gateways.FirestoreClientFactory) ports.UserRepository
20
21type UserController struct {
22 outputFactory OutputFactory
23 inputFactory InputFactory
24 repositoryFactory RepositoryFactory
25 clientFactory gateways.FirestoreClientFactory
26}
27
28func NewUserController(outputFactory OutputFactory, inputFactory InputFactory, repositoryFactory RepositoryFactory, clientFactory gateways.FirestoreClientFactory) User {
29 return &UserController{
30 outputFactory: outputFactory,
31 inputFactory: inputFactory,
32 repositoryFactory: repositoryFactory,
33 clientFactory: clientFactory,
34 }
35}
36
37func (u *UserController) AddUser(ctx context.Context) func(c echo.Context) error {
38 return func(c echo.Context) error {
39 user := new(entities.User)
40 if err := c.Bind(user); err != nil {
41 return err
42 }
43
44 return u.newInputPort(c).AddUser(ctx, user)
45 }
46}
47
48func (u *UserController) GetUsers(ctx context.Context) func(c echo.Context) error {
49 return func(c echo.Context) error {
50 return u.newInputPort(c).GetUsers(ctx)
51 }
52}
53
54func (u *UserController) newInputPort(c echo.Context) ports.UserInputPort {
55 outputPort := u.outputFactory(c)
56 repository := u.repositoryFactory(u.clientFactory)
57 return u.inputFactory(outputPort, repository)
58}
UserControllerのメソッドであるAddUserとGetUsersでは、echo.POSTの第二引数の型であるfunc(c echo.Context) error を返しています。
なので、基本的には元々そちらに書かれていた内容を満たすような実装をしているだけですね。
Database(Firestore)
これは単純に、Firestoreのクライアントを取得するための実装です。
1package database
2
3import (
4 "context"
5
6 "cloud.google.com/go/firestore"
7 firebase "firebase.google.com/go"
8 "google.golang.org/api/option"
9)
10
11type MyFirestoreClientFactory struct{}
12
13func (f *MyFirestoreClientFactory) NewClient(ctx context.Context) (*firestore.Client, error) {
14 sa := option.WithCredentialsFile("credentialsFile.json")
15 app, err := firebase.NewApp(ctx, nil, sa)
16 if err != nil {
17 return nil, err
18 }
19
20 client, err := app.Firestore(ctx)
21 if err != nil {
22 return nil, err
23 }
24 return client, nil
25}
Gatewaysで宣言している、FirestoreClientFactoryを実装している感じですね。
Gatewaysの項目でも説明しましたが、これで容易に外部から本番環境とか開発環境の変更をすることが可能です。
Drivers
Driversでは、Controllerを呼び出し、echoの設定を行います。
1package drivers
2
3import (
4 "context"
5 "firestore_clean/adapters/controllers"
6
7 "github.com/labstack/echo/v4"
8)
9
10type User interface {
11 ServeUsers(ctx context.Context, address string)
12}
13
14type UserDriver struct {
15 echo *echo.Echo
16 controller controllers.User
17}
18
19func NewUserDriver(echo *echo.Echo, controller controllers.User) User {
20 return &UserDriver{
21 echo: echo,
22 controller: controller,
23 }
24}
25
26func (driver *UserDriver) ServeUsers(ctx context.Context, address string) {
27 driver.echo.POST("/users", driver.controller.AddUser(ctx))
28 driver.echo.GET("/users", driver.controller.GetUsers(ctx))
29 driver.echo.Logger.Fatal(driver.echo.Start(address))
30}
今回は、笹川先生の記事にはなかったツールとして、wireを使用してみました。
DIする時の面倒な初期化処理を自動で生成してくれるツールです。
1//go:build wireinject
2// +build wireinject
3
4package drivers
5
6import (
7 "context"
8 "firestore_clean/adapters/controllers"
9 "firestore_clean/adapters/gateways"
10 "firestore_clean/adapters/presenters"
11 "firestore_clean/database"
12 "firestore_clean/usecases/interactors"
13
14 "github.com/google/wire"
15 "github.com/labstack/echo/v4"
16)
17
18func InitializeUserDriver(ctx context.Context) (User, error) {
19 wire.Build(NewFirestoreClientFactory, echo.New, NewOutputFactory, NewInputFactory, NewRepositoryFactory, controllers.NewUserController, NewUserDriver)
20 return &UserDriver{}, nil
21}
22
23func NewFirestoreClientFactory() database.FirestoreClientFactory {
24 return &database.MyFirestoreClientFactory{}
25}
26
27func NewOutputFactory() controllers.OutputFactory {
28 return presenters.NewUserOutputPort
29}
30
31func NewInputFactory() controllers.InputFactory {
32 return interactors.NewUserInputPort
33}
34
35func NewRepositoryFactory() controllers.RepositoryFactory {
36 return gateways.NewUserRepository
37}
上記のコードを書いた後、drivers上でwireコマンドを実行することで、以下のコードが生成されます。
1// Code generated by Wire. DO NOT EDIT.
2
3//go:generate go run github.com/google/wire/cmd/wire
4//go:build !wireinject
5// +build !wireinject
6
7package drivers
8
9import (
10 "context"
11 "firestore_clean/adapters/controllers"
12 "firestore_clean/adapters/gateways"
13 "firestore_clean/adapters/presenters"
14 "firestore_clean/database"
15 "firestore_clean/usecases/interactors"
16 "github.com/labstack/echo/v4"
17)
18
19// Injectors from wire.go:
20
21func InitializeUserDriver(ctx context.Context) (User, error) {
22 echoEcho := echo.New()
23 outputFactory := NewOutputFactory()
24 inputFactory := NewInputFactory()
25 repositoryFactory := NewRepositoryFactory()
26 firestoreClientFactory := NewFirestoreClientFactory()
27 user := controllers.NewUserController(outputFactory, inputFactory, repositoryFactory, firestoreClientFactory)
28 driversUser := NewUserDriver(echoEcho, user)
29 return driversUser, nil
30}
31
32// wire.go:
33
34func NewFirestoreClientFactory() gateways.FirestoreClientFactory {
35 return &database.MyFirestoreClientFactory{}
36}
37
38func NewOutputFactory() controllers.OutputFactory {
39 return presenters.NewUserOutputPort
40}
41
42func NewInputFactory() controllers.InputFactory {
43 return interactors.NewUserInputPort
44}
45
46func NewRepositoryFactory() controllers.RepositoryFactory {
47 return gateways.NewUserRepository
48}
Controllerを初期化するために各interfaceの実装を指定して、最終的にDriversの初期化を行っているだけですね。
main.go
最後に、main.goでuser_driverを呼び出してみましょう!
1package main
2
3import (
4 "context"
5 "firestore_clean/drivers"
6 "fmt"
7 "os"
8)
9
10func main() {
11 ctx := context.Background()
12 userDriver, err := drivers.InitializeUserDriver(ctx)
13 if err != nil {
14 fmt.Printf("failed to create UserDriver: %s\n", err)
15 os.Exit(2)
16 }
17 userDriver.ServeUsers(ctx, ":8000")
18}
ここまで複雑な構造でしたが、main.goから見るとすごい簡単ですね!
動作確認
Postmanで確認してみると、以下のようになりました。
GetUsers
AddUser
ちゃんと動作してることが確認できました!
まとめ
取り扱ったコードは結構単純な処理だったので、クリーンアーキテクチャ化の恩恵は少し薄いかもしれませんが、仕様変更に強く、テストが書きやすいコードができたと思います。
今回のコードは以下のリポジトリにまとめてありますので、ぜひ確認してみてください!
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ