GolangでCleanArchitectureを学んだのでメモメモ
概要
個人的に Go で CleanArchitecture の学習を進めておりましたが、書いては捨て、書いては捨てを繰り返してようやく理解が深まってきたので記事にしておこうと思いました。
結論
難しく考えすぎてしまうので、CleanArchitecture とは何か?を簡潔に申し上げると以下の結論に至りました。
- CleanArchitecture とは、変更に強い柔軟なアプリケーションを開発するための概念
- 上記を満たすために各プロセスの依存関係が具体的な実装に依存しないようにする。
私自身学んでみて、現時点では上記の結論に辿り着きましたが、今後変わってくるかもしれませんし、人それぞれ違ってくると思うので参考までに。
why
では、なぜ CleanArchitecture で開発する必要があるのか、メリットは何かを考えていきたいと思います。
メリット
メリットは結論でも軽く触れましたが、改めて挙げていくと
- 変更に強いアプリケーションになる。
これに尽きると思います。他にも挙げれば大なり小なりメリットはあるかもしれませんが、CleanArchitecture で開発する最大のメリットこれかと。
なぜ変更に強いアプリケーションになる。
では、CleanArchitecture で開発するとなぜ変更に強くなるのかという部分にフォーカスして考えていきたいと思います。
CleanArchitecture で開発する際、以下の概念が重要になってきます。
- 各プロセスの依存関係は interface のみにとどめることによって、具体的な実装には依存しないようにする。
- さらに処理の中核を担う、usecase 層(いわゆるビジネスロジック)は依存関係逆転の原則を用いることによって、外部の影響を受けないようにする。
なんかもっともらしいこと言っているようですが、簡潔に順を追って説明すると、
- interface にのみ依存することによって、具体的な処理の変更に影響を受けない。
- 特にビジネスロジックは外部の具体的な処理に依存しないことによって、DB や Web フレームワークが変更されたとしても影響を受けなくて済む。
- 詰まるところ、変更に強いアプリケーションの完成
といった具合です。これは後に実際のソースコードを見ればなんとなく分かると思います。
上記で挙げた考え方は CleanArchitecture の図にも現れています。
こちらの図の内側は外側の変更に影響を受けないことになります。
上記の図と実際の処理の流れとの関係を draw.io で図にしました。
ここで注目していただきたいのが、処理は左から順に流れていますが、依存関係は usecase から repository(Gateway)の向きが反転しています。
これを依存関係逆転の法則と言います。
デメリット
では次に CleanArchitecture で開発することのデメリットについてですが、私は以下の 2 点かなと思いました
- コードが冗長になる。
- 学習コストがかかる。
後で記載していく簡単なコードを見ていただければ分かると思いますが、単純な処理でもそこそこのコード量になっちゃいます。
とはいえ、こちらの 2 点が許容できれば、変更・改修に強い柔軟なアプリケーションは世の中に求められている流れだと思いますので検討の余地は十分にあるかと。
How
ここからは実際にソースコードを見ていきたいと思います。
まずは処理の流れを CleanArchitecture の図に合わせてみていきたいと思います。
この図から分かる通り、
- 各境界線を跨ぐとき、依存先は Interface に向ける。(目的:具体的な実装の隠蔽、依存関係を逆転させるため)
- usecase と repository は依存関係を逆転させるため、境界線を repository の Interface の外側に引く。
上記のことがわかると思います。では実際のコードで確認していきましょう。
Golang で CleanArchitecture
今回は簡単なログイン機能で各処理をみていきたいと思います。(概念を理解する目的なので、実際のコード内容には触れません。)
model
type User struct {
ID uint `json:"id"`
Name string `json:"name"`
Password string `json:"password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
router
// ...省略
func NewRouter(uc controller.IUserController) *echo.Echo {
e := echo.New()
e.POST("/login", uc.Login)
e.Use(middleware.Logger())
return e
}
router の役割は
- ルーティングの設定
- controller の呼び出し
- ミドルウェアの設定等
です。今回は Go の Web フレームワークとして、Echo を使用しました。
controller
import (
// ...一部省略
"github.com/labstack/echo/v4"
)
// interface
type IUserController interface {
Login(c echo.Context) error
}
// interfaceを実装するstruct
type userController struct {
uu usecase.IUserUsecase
}
// コンストラクタ
func NewUserController(uu usecase.IUserUsecase) IUserController {
return &userController{uu: uu}
}
func (uc *userController) Login(c echo.Context) error {
user := model.User{}
if err := c.Bind(&user); err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
token, err := uc.uu.Login(user)
if err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
cookie := http.Cookie{
Name: "token",
Value: token,
Path: "/",
Domain: os.Getenv("API_DOMAIN"),
Expires: time.Now().Add(24 * time.Hour),
Secure: true, //Postmanでテストするときはコメントアウト
HttpOnly: true,
SameSite: http.SameSiteNoneMode, // フロントSPAのため、None
}
c.SetCookie(&cookie)
return c.NoContent(http.StatusOK)
}
これ以降の controller〜repository まで以下のような構成になります。
- interface
- interface の具体的な処理を実装する struct(他言語の class のような概念)
- コンストラクタ(NewXXX と命名しているもの)
- メソッドの定義
そして、controller では
- リクエストで送られてきたデータをバインド
- クッキーの設定
- レスポンスで送信する内容を決定
等の処理を担っています。
usecase
package usecase
import (
// ...一部省略
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type IUserUsecase interface {
Login(user model.User) (string, error)
}
type userUsecase struct {
ur repository.IUserRepository
}
func NewUserUsecase(ur repository.IUserRepository) IUserUsecase {
return &userUsecase{ur: ur}
}
func (uu *userUsecase) Login(user model.User) (string, error) {
storedUser := model.User{}
if err := uu.ur.GetUserByUserName(&storedUser, user.Name); err != nil {
return "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(user.Password)); err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": storedUser.ID,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
tokenString, err := token.SignedString([]byte(os.Getenv("SECRET")))
if err != nil {
return "", err
}
return tokenString, nil
}
usecase では上記で説明した通り、ビジネスロジックを実装していきます。ログイン機能のケースで言うと
- パスワードのハッシュ化
- JWT の生成
- バリデーションチェック(今回は省略)
などなどです。
repository
package repository
import (
// ...一部省略
"gorm.io/gorm"
)
type IUserRepository interface {
GetUserByUserName(user *model.User, name string) error
}
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) IUserRepository {
return &userRepository{db: db}
}
func (ur *userRepository) GetUserByUserName(user *model.User, name string) error {
if err := ur.db.Where("name = ?", name).First(user).Error; err != nil {
return err
}
return nil
}
repository の役割は
- SQL の生成と実行
です。今回は Gorm という O/R マッパーライブラリを使用した例になっています。
main.go
// ...省略
func main() {
connDB := db.NewDB()
userRepository := repository.NewUserRepository(connDB)
userUsecase := usecase.NewUserUsecase(userRepository)
userController := controller.NewUserController(userUsecase)
e := router.NewRouter(userController)
e.Logger.Fatal(e.Start(":8080"))
}
main 関数(エントリーポイント)では、各層のコンストラクタ経由で返されるインスタンスを次の層のコンストラクに渡していくという流れです。
このように各処理の中で必要なものをインスタンス化するのではなく、外部であらかじめコンストラクタ経由で依存先のインスタンスを受け取っておくことで、疎結合のアプリケーションにすることができます。
この流れを CleanArchitecture では「依存性の注入」と呼びます。
総論
上記のソースコードにはあえて一部 import 文を記載していきましたが、見ていただいてわかる通り、
- Web フレームワークを変更した場合(例:echo → gin など)
- 影響があるのは、router、controller
- DB や ORM ライブラリを変更した場合(例:postgresql → MySQL など)
- 影響があるのは、repository、db
このように各層を適切に分離できており、依存先が interface のみとなっていれば、ビジネスロジックに低レイヤーの変更の影響を与えずに済みます。
これで変更に強い、柔軟なアプリケーションの出来上がり〜となりました!
まとめ
以上が私が CleanArchitecture を学んでみて、たどり着いた結果です。
とはいえ、結局はソフトウェアを開発していく上での設計概念のようなものなので、一度学んでおいて損はありませんが、あまり細かいルールに固執する必要はないかと思いました。
特に依存関係逆転の原則の部分は当初、全く理解ができず何度も何度もやり直しました。当初は通常のケースと依存関係が逆転するケース何が違うの?(どちらもインターフェースのみに依存していることに変わりなくない?)と思ってましたが、CleanArchitecture の教科書(私が勝手にそう呼んでるだけです。)では、以下のように記述があります。
OO 言語が安全で便利なポリモーフィズムを提供しているというのは、ソースコードの依存関係は(たとえどこであっても)逆転できることを意味する。
こちらの一節だけでは何言っているのか伝わりづらかったかもしれませんが、要は都合のいいように考えてただけなのかと私は自分なりに納得しました。
(境界線をどこに引くかについても深く追求すれば、きちんとした論理があるのかもしれませんが、それよりも手を動かしていった方が良いかと思い、今回はそこまで深く追わなかったので、この結論になったのかもしれません...)
でも学んでみて良かったと思えたことは間違いありません。引き続き知見が増えたらアップデートかまた別の記事を出していこうと思います!
Discussion