自己紹介
文系大学院生をしているものです。
今回初Qiitaとなります。何にも考えずに執筆したので多分駄文です。お手柔らかにお願いいたします。
ここ違うよってところありましたら、お手数ですがコメントで教えていただけると助かります。
はじめに
この記事は、Web開発を初めて行なった際に一番苦労したシステム設計について書き残したものです。
コードはGolang、MySQLで書いています。(サンプルコード)
私はこれまでRailsでハンズオン記事やRailsTutorial等を用いてWeb開発をチュートリアル的に行なってきました。Railsではrails g した際に雛形が自動生成されるため、システム設計を意識することなく開発を行なってきました(それがRailsの良さであると理解しています)
今回はGo言語での開発を行いましたが最初につまづいたのはそこでした。1から自分で考えなくてはいけません。そこで「アプリ作る時 ディレクトリ構成」などとググったところ、そこでさまざまな設計法があると知りました。今回はその中でもClean Architectureを用いたシステム設計について初学者が考えたこと、調べたことを書き残したいと思います。
なお、理解できていなかったり自分なりに解釈している部分もあるので間違えてるところあったらご遠慮なくご指摘ください。
Clean Architectureとは
CleacArchitectureとは、Robert C. Martin氏が提唱したシステム設計の考え方です。システム設計の考え方としては、MVC(Model View Controller)と同じくらい有名な考え方です。下記のような図を見たことがある方も多いのではないでしょうか。
採用メリット
- 疎結合であり関心の分離により、変更に強い
- テストが楽になる(mock)
- 外部機能の独立(DB含む)
→ 今回は完全に従っているわけではないのでここの部分の旨味は薄いかも。信念による部分が大きい
デメリット
- コードが冗長化する。(データの詰め替え等)
- 学習コストが高い
要は、外側のレイヤは1つ内側のレイヤにのみ依存するように構造化したアーキテクチャで、アプリケーションと技術の分離を図ることが主目的となります。レイヤは4層からなり、外側からそれぞれ
- フレームワーク&ドライバー層
- インターフェースアダプター層
- ユースケース層
- エンティティ層
と分けられます。
それぞれの層には役割があり、それをレイヤの責務と呼びます。各レイヤは基本的には責務に従って実装していく形となります。
エンティティレイヤ
エンティティレイヤはドメインロジックを実装する責務を持ちます。レイヤードアーキテクチャにおけるドメインレイヤに相当します。そのため技術的な実装を持つことはありません。レイヤードアーキテクチャではドメインレイヤがインフラストラクチャレイヤの実装をラップすることがありましたが、クリーンアーキテクチャはアプリケーションと技術を分離するので、クリーンアーキテクチャのエンティティレイヤは技術的な実装をラップする必要はありません。
ユースケースレイヤ
ユースケースレイヤはエンティティレイヤのオブジェクトを操作して任意のビジネスロジックを実行する責務を持ちます。レイヤードアーキテクチャにおけるアプリケーションレイヤに相当します。ユースケースレイヤはアダプターレイヤに依存されるため、アダプターの実装に対するポートを定義することが可能です。
インターフェースアダプターレイヤ
インターフェースアダプターレイヤはヘキサゴナルアーキテクチャのアダプターレイヤに相当します。ヘキサゴナルアーキテクチャではポートに対する実装を提供しますが、クリーンアーキテクチャでも同様です。クリーンアーキテクチャではユースケースレイヤが持つポートに対して実装を提供します。
フレームワーク・ドライバレイヤ
フレームワーク・ドライバーレイヤは DB やメールなどの技術詳細に関する実装を持つ レイヤです。Web であれば MVC フレームワークそのものが該当しますし、DB であれば MySQL のドライバなどが実装します。このレイヤの実装は少ないでしょう。
【引用】pospomeのサーバサイドアーキテクチャ(PDF版)
普通に挫折した
これを初めて見た時、頭の中はこんな感じでした。
ただえさえ初めて触るGoでの開発なのに、システム設計で躓いてしまいました。Railsは偉大です。今思えば画像&pospome様の資料は分かりやすいと感じるのですが、当時は全く理解できていませんでした。そこで、私なりに解釈し直して以下のようにコードを書いていくことにしました。
アーキテクチャ図 | 今回の実装 |
---|---|
フレームワーク&ドライバー層 | インフラストラクチャ層 |
ユースケース層 | ユースケース層 |
インターフェースアダプター層 | コントローラー層 |
エンティティ層 | ドメイン層 |
インフラストラクチャ層
データベースなどの外部との通信部分の実装。フレームワークを使った処理などはここ。
インターフェース層
ユースケースと外部を接続するための処理を実装。今回はコントローラーのみを実装。ここで言うコントローラーはmvcのそれと一緒。
ユースケース層
アプリ固有のビジネスロジックを実装。エンティティ層のオブジェクトを使った処理を書く。
ちなみに私はビジネスロジックという言葉の意味もよく分かってませんでした。以下わかりやすかった引用です。
例えばユーザー情報を保存する機能の場合、「全ての値が正常値か検証する。正常であればユーザーを更新 or 生成」などでしょうか。
ドメイン層
モデル定義とデータベース通信のためのインターフェースを書きました。
CleanArchitectureとは ②
採用メリットで「疎結合」「関心の分離」というワードを上げました。
ですが、頭の中はハテナでいっぱいです。調べました。
- 疎結合・・・各レイヤーごとは相互に連携しているが、相互の依存している余地が少ないこと。
ここでいう依存とは「あるコードがあるコードの中身を知っている状態にあること」です。usecase層で作った関数を外側のレイヤであるcontroller層が直接参照するみたいなことです。その時ContorollerはUsercaseを知っているということになります。すると下位モジュールの変更に上位モジュールが影響されてしまいます(密結合) - 関心の分離・・・責務の分離、Separation Of Concerns/SOC)とも呼ばれ、関数やクラス(GoでいうPackage)が一つの関心(責務。役割)に専念している。複数の関心を一つに混ぜない。
関心の分離はレイヤ分けで実現できるとして、どのようにして疎結合にするのでしょうか??
そこで使うのがDI(Dependency Injection)です。(SOLID原則)
Go言語におけるDI(Dependency Injection)
全てを説明していると長くなるため以下の記事を参考にしていただけたらなと思います。
多分今回は、Constructer Injectionを使っていると思います。
簡単にまとめると、Goのinterfaceを使って各層はそれに依存させようねという形です。
ちなみにここでいうinterfaceとCleanArchitecture内に出てくるインターフェース層は全くの別物です。
以下で実際にどのように実装したかを見ていきましょう。
実装
Package構成
実際は複数ドメインがありますが、今回はUserの処理のみに簡易化した構成で行きます。
ちなみにフレームワークはGormとEchoを採用しました。
├── domain // 実際はドメイン名で空間分けしています。
│ ├── model
│ │ └── user.go
│ └── repository
│ └── user.go
├── infrastructure
│ ├── repository
│ │ └── user.go
│ ├── router.go
│ └── mysql.go
├── interface
│ └── user.go
├── usecase
│ └── user.go
├── server.go
└── Dockerfile
サンプルコード
domain層
modelではユーザーのドメインモデルを定義しています。
package model
import "time"
type User struct {
ID uint `json:"id" gorm:"primary_key"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
Email string `json:"email" gorm:"unique"`
Password string `json:"password"`
Age uint `json:"age"`
Role string `json:"role"`
IdNumber string `json:"id_number"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
次にUserRepositryを定義します。ここではmodelオブジェクトの永続化を行う処理をinterfaceとして定義し、実際の処理自体はinfrastructure層に実装します。(依存性の方向を守るため)
直接永続化せず、UserRepositoryに依頼することで、DBなどの要因に依存しない変更ができるようになります。
package user
import "backend/domain/model"
type UserRepository interface {
Store(*model.User) (*model.User, error)
Update(*model.User) (*model.User, error)
DeleteById(id int) error
FindAll() (*model.Users, error)
FindById(id int) (*model.User, error)
FindByEmail(string) (*model.User, error)
}
infrastracture層
ここでは,domain層のUserRepositoryで定義したinterfaceを満たすメソッドを持つ構造体を実装します。
NewUserRepositoryは返り値として、domain層で定義したinterfaceを返します。
フレームワークのGormを使った処理です。
package user
import (
"domain/model"
"domain/repository/user"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) user.UserRepository {
return &UserRepository{db}
}
func (repo *UserRepository) FindAll() (*model.Users, error) {
var users model.Users
if err := repo.db.Find(&users).Error; err != nil {
return nil, err
}
return &users, nil
}
//以下略
infrastracture/mysql.goではDBのインスタンス作成の処理を書きます。
こいつがNewUserRepositoryの引数となります。
package infrastructure
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func NewDB() *gorm.DB {
//db情報は別途定義
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True",
dbUser,
dbPass,
dbHost,
dbPort,
dbName,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalln(err)
}
return db
}
ここでURLルーティングの役割や呼び出しの役割を取るrouter.goを実装しますが最後の方がわかりやすいと思いますのでここでは書きません。
usecase層
NewUserUsecaseが domainで定義したinterfaceを引数としてとっています。
これはUsecaseがdomainのinterfaceに依存していることを意味し、Repository自体には依存していません。
また、返り値はinterfaceであり、Domain層とInterface層との依存関係も解消されています(疎結合)
Mockを活用したテスト時に役立つらしいです。(私はテストまで手が回りませんでしたが...)
package user
import (
"backend/config"
"backend/domain/model"
"backend/domain/repository/user"
"fmt"
"time"
)
type IUserUsecase interface {
Users() (*AddOutputs, error)
}
type UserUsecase struct {
ur user.UserRepository
cnf *config.AppConfig
}
func NewUserUsecase(ur user.UserRepository, cnf *config.AppConfig) IUserUsecase {
return &UserUsecase{
ur: ur,
cnf: cnf,
}
}
func (uu *UserUsecase) Users() (*AddOutputs, error) {
users, err := uu.ur.FindAll()
if err != nil {
return nil, err
}
var outputs AddOutputs
for _, _u := range *users {
u := _u
outputs = append(outputs, AddOutput{&u})
}
return &outputs, nil
}
//以下略
ここでは本来modelオブジェクトをやりとりするのですが、このままだとこの処理を利用するInterface層がModelオブジェクトを参照できてしまうのはまずいです。なのでInputとOutputの構造体でオブジェクトの詰め替えを行っています。
package user
import "time"
type AddInputs []AddInputs
type AddInput struct {
ID uint `json:"id"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
Email string `json:"email"`
Password string `json:"password"`
Age uint `json:"age"`
Role string `json:"role"`
IdNumber string `json:"id_number"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
package user
import "backend/domain/model"
type AddOutputs []AddOutput
type AddOutput struct {
*model.User
}
Interface層
最後にInterface層です。MVCにおけるコントローラー部分を実装していきます。
結果をレスポンスで返します。
package user
import (
"backend/config"
"backend/usecase/user"
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
)
type UserController struct {
uu user.IUserUsecase
cnf *config.AppConfig
}
func NewUserController(uu user.IUserUsecase, cnf *config.AppConfig) *UserController {
return &UserController{
uu: uu,
cnf: cnf,
}
}
func (uc *UserController) Index(c echo.Context) error {
users, err := uc.uu.Users()
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
return c.JSON(http.StatusOK, users)
}
//以下略
呼び出し層(infrastracture/router.go)
最後に呼び出しです。各コンスラクタを呼び出して初期化します。
各層はInterfaceに依存した処理を書いているため、疎結合となります。そのため実装部分が変更したり、機能が追加されたりしてもその部分のみを改修すれば良いことになります。よくマトリョーシカのようであると形容されますが、的を得ていると思います。
package infrastructure
import (
"backend/config"
usre "backend/infrastructure/repository/user"
usco "backend/interfaces/controller/user"
usus "backend/usecase/user"
"net/http"
"os"
"github.com/labstack/echo/v4"
)
func Init() {
db := NewDB()
cnf := config.NewAppConfig()
userRepository := usre.NewUserRepository(db)
userUsecase := usus.NewUserUsecase(userRepository, cnf)
userController := usco.NewUserController(userUsecase, cnf)
//User Routes
e.GET("/users", userController.Index)
//以下略
}
クリーンアーキテクチャでは、handler→usecase→service→repositoryと言う依存関係があるので、handlerの初期化にはusecaseが、usecaseの初期化にはseriviceが、serviceの初期化にはrepositoryが必要になってくる。その際、使用するのが各ファイルに実装してあるNewXXXXと言う関数(コンストラクタ)である。コンストラクタの返り値はinterfaceにしておくことで、各層をinterfaceで接続でき、疎結合を実現できる。各層で定義したコンストラクタを呼び出してhandlerを作成し、後続の処理に渡す。要は、handlerではマトリョーシカ🪆を作り、🪆を一つ開けてusecaseに渡しserivice, repositoryでも同じ感じに渡していくイメージ。
【引用】Clean Architecture with Go
なぜClean Architectureを選定したか
そもそも、CleanArchitecture自体の概念を知ったのは、私がお世話になっている企業のメンターの方同士の会話で出てきたことがきっかけです。「CleanArchitecrtureベースで〜」みたいな会話をされてて、なんだそれと思って調べたら円のアーキテクチャ図が出てきましたことを覚えています。その時はそっ閉じしましたが。
そして今回は開発中のアプリを研究室の後輩とチーム開発していくことを視野に入れているため選定しました。コードが冗長になるというデメリットはあるものの、先にインターフェースで関数を定義しておけばその実装自体は双方に依存しないので、両方から同時に実装できる開発スピード的なメリットもあるなと感じました。
また、疎結合なので機能改善や追加も簡単に行えるのは個人で開発している僕でさえ実感できました。ただこの部分は何をどこまで抽象化するかで議論の余地があるみたいですね。
また、学習の意味合いも込めています。
アーキテクチャは現場でも大活躍...
そんな使われてないらしいです(私調べ)現場にもよるらしいですが。
でも色々学習できて面白かったです。
今後
今回Goで個人開発するにあたって、CleanArchitectureで実装してみました。初学者にはかなり学習コストが高い内容でしたが、習得のための周辺知識含めて様々なことが学べて楽しかったです。
今後アプリ開発はインフラとフロントの勉強を進めていこうと思います(インフラ苦戦中)
ここまで1人でやったの?
正直1人でやっていたら確実に挫折していたと思います。他の方法探るとか。Railsのままやるとか。
そんなときTechTrainでプロのエンジニアの方にコードレビューをしていただき、改善点の指摘やらメンタリングやらで大変お世話になりました。メンタル的にもいつでも相談できるメンターがいることはプラスになると思います。
私は今回Takumaさんをメンターとして選択させていただきました。
Takumaさんは現在、株式会社CyberAgentでエンジニアとしてご活躍されている方です。
選択させていただいた理由としては、
- 私の今回の選定技術の相談が可能である点(Go+React+MySQL+AWS)
- Webアプリ設計に関して相談が可能である点(CleanArchitecture)
- 夜(20:00〜)と休日に面談可能な点
何度も面談をしていただく中で、以下のようなメリットがありました。
- コードレビューしていただけた
- アーキテクチャに関する質問の解答をいただけた
- 僕の検索が下手なだけかも知れませんが、アーキテクチャは現場ごとにちょっと変形されたりしているので初学者の僕ではトラブルシューティングに時間がかかりがちでした。
- たまに宿題も出していただき、学習指針の支えとなった
- Goの作法に関しても教えていただけた
- 言語化がむずかしい質問に関しても、補完して的確に解答いただけた(質問をする練習にもなった)
- 就活相談にも乗っていただけた
- 就活に関しては、Takumaさんの他にもたくさんの方にお世話になったので別途記事を書こうと思います
- メンタルケア。褒めてくれる。
- 私はチョロいのでちょっと褒めていただけるだけでも嬉しくなります。
Qiita記事も参考にさせていただきました。
また他にもさまざまな分野に特化したメンターの方がいらっしゃるので、特に実務未経験の方や就活中の方には特にTechTrainおすすめです。
おわりに
はじめてのQiita緊張しました。
ここまで読んでいただきありがとうございました。
参考文献