今年の2月からLaravelで開発を行っています。そんな中、一番詰まったのは
「責務を分けて実装」
でした。クリーンアーキテクチャってやつです。
正直今でも感覚的にしかわかっていない点がありますが、今年のまとめとしてクリーンアーキテクチャの復習をするつもりで趣味で触っているGoを使ってREST APIを実装してみました。(gRPCじゃないのかよって突っ込まれそう)
年の瀬に、一番苦手だったことを記事にして恥を晒して年越しします。
アドベントカレンダーのトリがそんなんですみません。
どこかに飛んでいきたい・・・・鳥だけに。
クリーンアーキテクチャ
クリーンアーキテクチャと検索すれば必ず出てくる有名な図です。
このアーキテクチャにすることによって機能を実現しているコアな部分をフレームワークやDBなどに依存しない状態にすることで、他が変わってもコアな部分への影響をなくし、変更や拡張に強くすることが期待できます。
具体的には下記のようなメリットがあります。
- ビジネスロジックが明確
- 改修の際にどこに手を入れればいいか特定しやすい
- 他の機能への影響を減らせる
- テストがやりやすくなる
- テストコードを書きやすい
REST API実装
使用技術
- Language: Go
- FW: Echo
- ORM: Gorm
- DB: PostgreSQL
参考資料
個人的にはentが気になっているのですが、今回はGoの中でもオーソドックスな組み合わせで実装してみました。
entのメリットは下記に詳しく書かれています。
ファイル構成
Todoアプリをイメージしています。
REST-API/
┣ controller/
┃ ┣ task_controller.go
┃ ┗ user_controller.go
┝ db/
┃ ┗ db.go
┝ migrate/
┃ ┗ migrate.go
┝ model/
┃ ┝ task.go
┃ ┗ user.go
┝ repository/
┃ ┝ task_repository.go
┃ ┗ user_repository.go
┝ router/
┃ ┗ router.go
┝ usecase/
┃ ┝ task_usecase.go
┃ ┗ user_usecase.go
┝ validator/
┃ ┝ task_validator.go
┃ ┗ user_validator.go
┝ .env
┝ docker-compose.yml
┝ go.mod
┝ go.sum
┗ main.go
ロジックの実装について
今回はuserに関するファイルのみに絞ってお話しします。
Repository
Repositoryは外部データへのアクセスを行います。主にRepositoryの役割は外部データの読み書きとなります。外部データのアクセスは、外部APIへのアクセスやデータベースへのアクセスなどサーバーサイド内部で完結しないものは全て担当し、それ以外のロジックは実装しません。Repositoryは扱うデータ、つまりEntityごとに実装します。
package repository
import (
"go-rest-api/model"
"gorm.io/gorm"
)
type IUserRepository interface {
GetUserByEmail(user *model.User, email string) error
CreateUser(user *model.User) error
}
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) IUserRepository {
return &userRepository{db}
}
func (ur *userRepository) GetUserByEmail(user *model.User, email string) error {
if err := ur.db.Where("email=?", email).First(user).Error; err != nil {
return err
}
return nil
}
func (ur *userRepository) CreateUser(user *model.User) error {
if err := ur.db.Create(user).Error; err != nil {
return err
}
return nil
}
例えば GetUserByEmail ではDBに登録されているemailを取得、CreateUserではユーザー登録というように直接DBとやり取りをしています。
Usecase
Usecaseとはビジネスロジックやアプリケーションの主要な機能を実現する部分を指します。ユーザーや外部のシステムとの対話や、アプリケーションの主要な機能を表現するために使用されます。
package usecase
import (
"go-rest-api/model"
"go-rest-api/repository"
"go-rest-api/validator"
"os"
"time"
"github.com/golang-jwt/jwt/v4"
"golang.org/x/crypto/bcrypt"
)
type IUserUsecase interface {
SignUp(user model.User) (model.UserResponse, error)
Login(user model.User) (string, error)
}
type userUsecase struct {
ur repository.IUserRepository
uv validator.IUserValidator
}
func NewUserUsecase(ur repository.IUserRepository, uv validator.IUserValidator) IUserUsecase {
return &userUsecase{ur, uv}
}
func (uu *userUsecase) SignUp(user model.User) (model.UserResponse, error) {
if err := uu.uv.UserValidate(user); err != nil {
return model.UserResponse{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10)
if err != nil {
return model.UserResponse{}, err
}
newUser := model.User{Email: user.Email, Password: string(hash)}
if err := uu.ur.CreateUser(&newUser); err != nil {
return model.UserResponse{}, err
}
resUser := model.UserResponse{
ID: newUser.ID,
Email: newUser.Email,
}
return resUser, nil
}
func (uu *userUsecase) Login(user model.User) (string, error) {
if err := uu.uv.UserValidate(user); err != nil {
return "", err
}
storedUser := model.User{}
if err := uu.ur.GetUserByEmail(&storedUser, user.Email); err != nil {
return "", err
}
err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(user.Password))
if err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": storedUser.ID,
"exp": time.Now().Add(time.Hour * 12).Unix(),
})
tokenString, err := token.SignedString([]byte(os.Getenv("SECRET")))
if err != nil {
return "", err
}
return tokenString, nil
}
ユーザーがログインを実現するためにLoginが実行されたり、登録を実現するためにSignUpが実行されたりとユーザーの動作をイメージすると分かりやすいかもしれません。
Controller
Controller はユーザの入力を解釈し、UseCase にそれを伝えます。
package controller
import (
"go-rest-api/model"
"go-rest-api/usecase"
"net/http"
"os"
"time"
"github.com/labstack/echo/v4"
)
type IUserController interface {
SignUp(c echo.Context) error
LogIn(c echo.Context) error
LogOut(c echo.Context) error
CsrfToken(c echo.Context) error
}
type userController struct {
uu usecase.IUserUsecase
}
func NewUserController(uu usecase.IUserUsecase) IUserController {
return &userController{uu}
}
func (uc *userController) SignUp(c echo.Context) error {
user := model.User{}
if err := c.Bind(&user); err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
userRes, err := uc.uu.SignUp(user)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusCreated, userRes)
}
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())
}
tokenString, err := uc.uu.Login(user)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
cookie := new(http.Cookie)
cookie.Name = "token"
cookie.Value = tokenString
cookie.Expires = time.Now().Add(24 * time.Hour)
cookie.Path = "/"
cookie.Domain = os.Getenv("API_DOMAIN")
cookie.Secure = true
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteNoneMode
c.SetCookie(cookie)
return c.NoContent(http.StatusOK)
}
func (uc *userController) LogOut(c echo.Context) error {
cookie := new(http.Cookie)
cookie.Name = "token"
cookie.Value = ""
cookie.Expires = time.Now()
cookie.Path = "/"
cookie.Domain = os.Getenv("API_DOMAIN")
cookie.Secure = true
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteNoneMode
c.SetCookie(cookie)
return c.NoContent(http.StatusOK)
}
func (uc *userController) CsrfToken(c echo.Context) error {
token := c.Get("csrf").(string)
return c.JSON(http.StatusOK, echo.Map{
"csrf_token": token,
})
}
依存関係
上記で挙げたrepository・usecase・controllerは基本的には下記のような関係性をイメージしています。
上記から分かるように、内側の階層に外側が依存することがわかります。Repositoryがうまく機能しないとContoroller・Usecaseがうまく機能していないことになってしまいます。
依存関係の逆転
内側に依存しているからといって、呼び出し元から内側まで全てを改修するのは大変です。
そこで 「依存関係逆転の法則」 というものがあります。上記のコード内でも IUserUsecase などインターフェースを実装している箇所があると思います。インターフェイスを置くことによるメリットとしては
- 利用者側(アプリーケーションサービス側)はコードを変更せずにリポジトリを変更できるようになる
- リポジトリを実装する人と、アプリケーションサービスを実装する人はお互いの実装を気にせず実装できる(実装の中にはどうだっていい)
つまりRepositoryの実装に修正があった場合でも、呼び出し元では修正の必要がなくなります。
これすごくわかりやすいです。
まとめ
今回かなり色々調べながらこの記事を書きました。
そんな中わかったことですが、
結論
「よくわかっていない」
でした。
こちら嬉しい動画付き
やっぱり難しい・・・・・
「クリーンアーキテクチャを理解する」
を来年の目標としたいと思います。
では良いお年を!