2
Help us understand the problem. What are the problem?

posted at

2年目エンジニアのClean Architecture

この記事では、Clean Architectureの概要、構築をまとめています。

すでにこれらの内容を含んだ記事は多く存在していますが、私自身2年目に突入したことを機により理解を深めたいと思ったため、本記事を投稿するに至りました。

私ごとではありますが、今回初の投稿となりますので、過不足な点や分かりづらい点などがございましたら、ご指摘など頂けますと幸いです。

今回特に@hirotakanさんのClean ArchitectureでAPI Serverを構築してみるを参考にさせて頂きました。
本記事は私が勉強がてら投稿したものになりますので、私のような初学者でこれから学習をしたい方には先述の記事をぜひオススメします。

Clean Architectureの概要

CleanArchitecture.jpg

クリーンアーキテクチャの目的は「関心の分離」、つまり「プログラムをユースケースや役割毎に分離」することです。

それによって以下のことが実現できます。

フレームワーク独立
アーキテクチャは、システムをライブラリに依存させず、なにかのフレームワークに限定されるような制約が発生しない。
テスト可能
ビジネスルールは、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もこの層に定義します。

entities/post.go
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として働きます。

usecase/post_interactor.go
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.goPostInteractorに埋め込むことで、外側のルールを知らずともユースケースを満たす要求が可能になります。

usecase/post_repoitory.go
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を利用します。

interfaces/database/post_repository.go
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の振る舞いを定義します。

interfaces/database/post_sql_handler.go
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.goPostSqlHandlerのロジックを定義しPostRepositoryに埋め込むことで、外側でどのようなDBフレームワークを使っていたとしても、内側のレイヤーに影響を与えることはありません。

infrastructure/sqlhandler/postSqlhandler.go
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を使っています。

interfaces/controllers/post_controller.go
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の引数に渡すことが可能です。

interfaces/controllers/context.go
package controllers

type Context interface {
    QueryParam(string) string
	FormValue(string) string
}

routingにはEchoを使っているため、infrastructureレイヤーに定義します。

infrastructure/routing/router.go
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を生成します。

infrastructure/sqlhandler/sqlhandler.go
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ファイルも作成しています。

infrastructure/routing/postRouter.go
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サーバーとして動作します。

main.go
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翻訳)
クリーンアーキテクチャ完全に理解した
実装クリーンアーキテクチャ

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
2
Help us understand the problem. What are the problem?