この記事では、Clean Architectureの概要、構築をまとめています。
すでにこれらの内容を含んだ記事は多く存在していますが、私自身2年目に突入したことを機により理解を深めたいと思ったため、本記事を投稿するに至りました。
私ごとではありますが、今回初の投稿となりますので、過不足な点や分かりづらい点などがございましたら、ご指摘など頂けますと幸いです。
今回特に@hirotakanさんのClean ArchitectureでAPI Serverを構築してみるを参考にさせて頂きました。
本記事は私が勉強がてら投稿したものになりますので、私のような初学者でこれから学習をしたい方には先述の記事をぜひオススメします。
Clean Architectureの概要
クリーンアーキテクチャの目的は「関心の分離」、つまり「プログラムをユースケースや役割毎に分離」することです。
それによって以下のことが実現できます。
- フレームワーク独立
- アーキテクチャは、システムをライブラリに依存させず、なにかのフレームワークに限定されるような制約が発生しない。
- テスト可能
- ビジネスルールは、UI、データベース、ウェブサーバー、その他外部の要素なしにテストできる。
- UI独立
- UIは、ビジネスルールの変更なしに置き換えることが可能。
- データベース独立
- OracleあるいはSQL Serverを、Mongo, BigTable, CoucheDBあるいは他のものと交換することができる。ビジネスルールは、データベースに拘束されない。
- 外部機能独立
- ビジネスルールは、外側についてなにも知らない
依存ルール
このアーキテクチャを機能させる重要なルールが、依存ルールだ。このルールにおいては、ソースコードは、内側に向かってのみ依存することができる。内側の円は、外側の円についてなにも知ることはない。とくに、外側の円で宣言されたものの名前を、内側の円から言及してはならない。これは、関数、クラス、変数、あるいはその他、名前が付けられたソフトウェアのエンティティすべてに言える。
同様に、外側の円で使われているデータフォーマットを内側の円で使うべきではない。とくに、それらのフォーマットが、外側の円でフレームワークによって生成されているのであれば。外側の円のどんなものも、内側の円に影響を与えるべきではないのだ。
内側は外側のルールに依存してはいけません。
外側のフレームワークによって定義されているクラスのインスタンスを、内側の円で生成してしまうことなどはこの依存ルールに反してしまいます。
上記の図を見ていると、外側にあるDBにはどうやってアクセスするの?などいくつか疑問があるかもしれませんが、これをDIP(依存関係逆転の原則)によって解決します。
内側の層でインターフェースにより振る舞いを定義し、外側でその振る舞いを満たすロジックを定義することで、内側は外側のルールを知らずとも要求が可能になります。
各レイヤーについて
Entities
ビジネスルールのためのデータ構造と関数の集合、あるいはメソッドをもったオブジェクト。
Use cases
アプリケーション固有のビジネスルール。エンティティからの、あるいはエンティティーへのデータの流れを組み立て、ユースケースの目的を達成する。
Interface
外部形式から、ユースケースとエンティティーで使われる内部形式にデータを変換、または内部から外部の機能に向けてもっとも便利な形式に、データを変換するアダプター
Frameworks & Drivers
データベースやウェブフレームワークなどのフレームワークやツールから構成される。一般に、このレイヤーに多くのコードは書かないが、ひとつ内側の円と通信するつなぎのコードはここに含まれる。
動作環境
- Mac OS Big Sur 11.5.2
- VSCode 1.63.2
- go1.17.0-bullseye
- Echo
- gcloud datastore
- gcloud-gui
- docker
実際のアプリケーションはdocker上で動作しており、APIサーバー、datastore、guiの3つのコンテナが起動しています。
実装について
入力フォームからの投稿のCRUDを想定した簡単な実装です。
エンドポイントは以下となります。
-
POST /createPost
- 投稿の追加
-
GET /posts
- 投稿一覧の取得
-
POST /updatePost
- 投稿の更新
-
GET /removePost
- 投稿の削除
ディレクトリ構成
└── src
├── server
│ ├── entities
│ ├── infrastructure
│ ├── interfaces
│ │ ├── controllers
│ │ └── database
│ ├── main.go
│ └── usecase
各ディレクトリとレイヤーの関係性は次のようになっています。
ディレクトリ名 | レイヤー |
---|---|
entities | Entities |
infrastructure | Frameworks & Drivers |
interfaces | Interface |
usecase | Use Cases |
Entitiesレイヤー
まずは、Entitiesレイヤーです。
Post
は、ビジネスルールの為のデータ構造なのでこの層に属します。
Post
にはIDとTextを持たせ、Post
のリストを持ったPosts
もこの層に定義します。
package entities
type Post struct {
ID int64 `json:"id" datastore:"-"`
Text string `json:"text" datastore:"text"`
}
type Posts struct {
Posts []Post `json:"posts"`
}
Use Casesレイヤー
次にUse Caseレイヤーです。
このレイヤーはデータの流れを組み立てる際の中継地点のような役割で、interfaces/controllers
からメソッド呼び出しと結果の返却、interfaces/database
へのInput Portとして働きます。
package usecase
import (
"server/entities"
)
type PostInteractor struct {
PostRepository PostRepository
}
func (interactor *PostInteractor) Add(post entities.Post) error {
err := interactor.PostRepository.Store(post)
return err
}
func (interactor *PostInteractor) Posts() (posts entities.Posts ,err error) {
posts, err = interactor.PostRepository.FindAll()
return posts, err
}
func (interactor *PostInteractor) Remove(id int64) error {
err := interactor.PostRepository.Remove(id)
return err
}
func (interactor *PostInteractor) Update(post entities.Post) error {
err := interactor.PostRepository.Update(post)
return err
}
依存ルールに反しないために、interfaceを用いて振る舞いを定義します。
このPostRepository
を外側で定義しusecase/post_interactor.go
のPostInteractor
に埋め込むことで、外側のルールを知らずともユースケースを満たす要求が可能になります。
package usecase
import (
"server/entities"
)
type PostRepository interface {
Store(entities.Post) error
FindAll() (entities.Posts, error)
Remove(int64) error
Update(entities.Post) error
}
Interfaces/database, Frameworks & Drivers/sqlhandlerレイヤー
このレイヤーでは、Use CasesのInput Port(usecase/post_repoitory.go
)を利用し、そのロジックを定義します。
また、外側にあるDBへの指示を行うためusecase/post_repoitory.go
のように振る舞いを定義し、DIPを利用します。
package database
import (
"server/entities"
)
type PostRepository struct {
PostSqlHandler
}
func (repo *PostRepository) Store(post entities.Post) error {
err := repo.AddPost("Post", post)
if err != nil {
return err
}
return nil
}
func (repo *PostRepository) FindAll() (posts entities.Posts, err error) {
posts, err = repo.GetAllPosts("Post")
if err != nil {
return posts, err
}
return posts, nil
}
func (repo *PostRepository) Remove(id int64) error {
err := repo.Delete("Post", id)
if err != nil {
return err
}
return nil
}
func (repo *PostRepository) Update(post entities.Post) error {
err := repo.UpdatePost("Post", post)
if err != nil {
return err
}
return nil
}
Use Casesレイヤーと同様に、このレイヤーでPostSqlHandler
の振る舞いを定義します。
package database
import (
"server/entities"
)
type PostSqlHandler interface {
AddPost(string, entities.Post) error
GetAllPosts(string) (entities.Posts, error)
Delete(string, int64) error
UpdatePost(string, entities.Post) error
}
infrastructure/sqlhandler/postSqlhandler.go
でPostSqlHandler
のロジックを定義しPostRepository
に埋め込むことで、外側でどのようなDBフレームワークを使っていたとしても、内側のレイヤーに影響を与えることはありません。
package sqlhandler
import (
"cloud.google.com/go/datastore"
"server/entities"
"fmt"
"context"
)
func(handler *SqlHandler) AddPost(keyName string, post entities.Post) error {
key := datastore.NameKey(keyName, "", nil)
_, err := handler.Client.Put(context.Background(), key, &post)
if err != nil {
fmt.Printf("datastore Put() error: %s", err)
return err
}
return nil
}
func(handler *SqlHandler) GetAllPosts(keyName string) (posts entities.Posts, err error) {
var dstPosts []entities.Post
keys, err := handler.Client.GetAll(context.Background(), datastore.NewQuery(keyName), &dstPosts)
if err != nil {
fmt.Printf("datastore GetAll() error: %s", err)
return posts, err
}
for i, key := range keys {
dstPosts[i].ID = key.ID
}
posts.Posts = dstPosts
return posts, nil
}
func(handler *SqlHandler) UpdatePost(keyName string, post entities.Post) error {
key := datastore.IDKey(keyName, post.ID, nil)
_, err := handler.Client.Put(context.Background(), key, &post)
if err != nil {
fmt.Printf("datastore Put() error: %s", err)
return err
}
return nil
}
func(handler *SqlHandler) Delete(keyName string, id int64) error {
key := datastore.IDKey(keyName, id, nil)
err := handler.Client.Delete(context.Background(), key)
if err != nil {
fmt.Printf("datastore Delete() error: %s", err)
return err
}
fmt.Printf("datastore Deleted id: %s", id)
return nil
}
Interfaces/controllers, Frameworks & Driversレイヤー
次にcontrollerとroutingについてです。
今回はWebフレームワークにEchoを使っています。
package controllers
import (
"server/usecase"
"server/interfaces/database"
"server/entities"
"fmt"
"strconv"
)
type PostController struct {
Interactor usecase.PostInteractor
}
func NewPostController(postSqlhandler database.PostSqlHandler) (postController *PostController) {
return &PostController{
Interactor: usecase.PostInteractor{
PostRepository: &database.PostRepository{
PostSqlHandler: postSqlhandler,
},
},
}
}
func (controller *PostController) Create(c Context) error {
post := entities.Post {
Text: c.FormValue("text"),
}
err := controller.Interactor.Add(post)
if err != nil {
fmt.Printf("Interactor Add() error: %s", err)
}
return err
}
func (controller *PostController) Index() (entities.Posts, error) {
posts, err := controller.Interactor.Posts()
if err != nil {
fmt.Printf("Interactor Posts() error: %s", err)
}
return posts, err
}
func (controller *PostController) Remove(c Context) error {
idQueryParam := c.QueryParam("id")
id, _ := strconv.ParseInt(idQueryParam, 10, 64)
err := controller.Interactor.Remove(id)
if err != nil {
fmt.Printf("Interactor Remove() error: %s", err)
}
return err
}
func (controller *PostController) Update(c Context) error {
id, _ := strconv.ParseInt(c.FormValue("id"), 10, 64)
post := entities.Post {
ID: id,
Text: c.FormValue("text"),
}
err := controller.Interactor.Update(post)
if err != nil {
fmt.Printf("Interactor Update() error: %s", err)
}
return err
}
PostController
のインスタンスを生成する際は、database.PostSqlHandler
を埋め込むようにします。
Echoのecho.Context
を内側に持ち込んではいけないため、Context用のInput Portを作成します。
echo.Context
は以下を満たしているため、controllerの引数に渡すことが可能です。
package controllers
type Context interface {
QueryParam(string) string
FormValue(string) string
}
routingにはEchoを使っているため、infrastructureレイヤーに定義します。
package routing
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func Init() {
e := echo.New()
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORS())
// set routing
SetPostRouting(e)
// start server
e.Logger.Fatal(e.Start(":8080"))
}
PostRepository
に埋め込むためのSqlHandler
を生成します。
package sqlhandler
import (
"cloud.google.com/go/datastore"
"context"
"os"
)
type SqlHandler struct {
Client *datastore.Client
}
func NewSqlHandler() *SqlHandler {
sqlHandler := new(SqlHandler)
ctx := context.Background()
projectID := os.Getenv("DATASTORE_PROJECT_ID")
client, err := datastore.NewClient(ctx, projectID)
if err != nil {
return sqlHandler
}
sqlHandler.Client = client
return sqlHandler
}
routingのファイルを機能毎に分割したかったので、post用のroutingファイルも作成しています。
package routing
import (
"github.com/labstack/echo/v4"
"server/interfaces/controllers"
"net/http"
"server/infrastructure/sqlhandler"
)
func SetPostRouting(e *echo.Echo) {
postController := controllers.NewPostController(sqlhandler.NewSqlHandler())
e.POST("/createPost", func(c echo.Context) error {
err := postController.Create(c)
if err != nil {
return c.JSON(500, err)
}
return c.JSON(http.StatusOK, "OK!")
})
e.GET("/posts", func(c echo.Context) error {
docs, err := postController.Index()
if err != nil {
return c.JSON(500, docs)
}
return c.JSON(http.StatusOK, docs)
})
e.GET("/removePost", func(c echo.Context) error {
err := postController.Remove(c)
if err != nil {
return c.JSON(500, err)
}
return c.JSON(http.StatusOK, "OK!")
})
e.POST("/updatePost", func(c echo.Context) error {
err := postController.Update(c)
if err != nil {
return c.JSON(500, err)
}
return c.JSON(http.StatusOK, "OK!")
})
}
最後にmain.go
からroutingのInit()
を呼び出せば、APIサーバーとして動作します。
package main
import (
"server/infrastructure/routing"
)
func main() {
routing.Init()
}
最終的なツリー構造です。
└── src
└── server
├── entities
│ └── post.go
├── infrastructure
│ ├── routing
│ │ ├── postRouter.go
│ │ └── router.go
│ └── sqlhandler
│ ├── postSqlhandler.go
│ └── sqlhandler.go
├── interfaces
│ ├── controllers
│ │ ├── context.go
│ │ └── post_controller.go
│ └── database
│ ├── post_repository.go
│ └── post_sql_habdler.go
├── main.go
└── usecase
├── post_interactor.go
└── post_repository.go
動作
簡単に動作確認をしてみます。
// 投稿の保存
% curl -XPOST localhost:8080/createPost -H "Content-Type: application/x-www-form-urlencoded" -d "text=hoge"
"OK!"
// 投稿一覧の取得
% curl -X GET localhost:8080/posts
{"posts":[{"id":63,"text":"fghjkl,."},{"id":66,"text":"hoge"}]}
// 投稿の更新
% curl -XPOST localhost:8080/updatePost -H "Content-Type: application/x-www-form-urlencoded" -d "id=66&text=hogehogehoge"
"OK!"
% curl -X GET localhost:8080/posts
{"posts":[{"id":63,"text":"fghjkl,."},{"id":66,"text":"hogehogehoge"}]}
// 投稿の削除
% curl -X GET "localhost:8080/removePost?id=66"
"OK!"
% curl -X GET localhost:8080/posts
{"posts":[{"id":63,"text":"fghjkl,."}]}
問題なく動作しているようです。。!
まとめ
個人的にClean Architectureに沿って実装することで、システムの可読性がグッと上がったように感じましたが、実際慣れるまでは単純なCRUDだけでも実装にかなり苦労しました。
Clean ArchitectureでAPI Serverを構築してみるで挙げられていましたが、たしかに外部ライブラリを触るためにinfrastructure層にラップ処理を定義(今回で言うとContext)したりとコードが冗長になってしまう節があるので、そこは悩みどころですね。
認識を間違えているところもあるかと思いますが、記事を書く事で改めて整理が出来ました。
今回docker周りでdatastoreとguiを使ったので、そちらの構築も別途記事にできればと思います。
今回のコードはこちらにて公開しています。
参考文献
Clean ArchitectureでAPI Serverを構築してみる
クリーンアーキテクチャ(The Clean Architecture翻訳)
クリーンアーキテクチャ完全に理解した
実装クリーンアーキテクチャ