10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HajimariAdvent Calendar 2023

Day 25

GoでREST APIを実装し、クリーンアーキテクチャを整理してみた

Posted at

今年の2月からLaravelで開発を行っています。そんな中、一番詰まったのは

「責務を分けて実装」

でした。クリーンアーキテクチャってやつです。
正直今でも感覚的にしかわかっていない点がありますが、今年のまとめとしてクリーンアーキテクチャの復習をするつもりで趣味で触っているGoを使ってREST APIを実装してみました。(gRPCじゃないのかよって突っ込まれそう)

年の瀬に、一番苦手だったことを記事にして恥を晒して年越しします。
アドベントカレンダーのトリがそんなんですみません。
どこかに飛んでいきたい・・・・鳥だけに。

クリーンアーキテクチャ

image.png

クリーンアーキテクチャ(The Clean Architecture翻訳)

クリーンアーキテクチャと検索すれば必ず出てくる有名な図です。
このアーキテクチャにすることによって機能を実現しているコアな部分をフレームワークや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は基本的には下記のような関係性をイメージしています。

image.png

上記から分かるように、内側の階層に外側が依存することがわかります。Repositoryがうまく機能しないとContoroller・Usecaseがうまく機能していないことになってしまいます。

依存関係の逆転

内側に依存しているからといって、呼び出し元から内側まで全てを改修するのは大変です。
そこで 「依存関係逆転の法則」 というものがあります。上記のコード内でも IUserUsecase などインターフェースを実装している箇所があると思います。インターフェイスを置くことによるメリットとしては

  • 利用者側(アプリーケーションサービス側)はコードを変更せずにリポジトリを変更できるようになる
  • リポジトリを実装する人と、アプリケーションサービスを実装する人はお互いの実装を気にせず実装できる(実装の中にはどうだっていい)

つまりRepositoryの実装に修正があった場合でも、呼び出し元では修正の必要がなくなります。

これすごくわかりやすいです。

まとめ

今回かなり色々調べながらこの記事を書きました。
そんな中わかったことですが、
結論

「よくわかっていない」

でした。

こちら嬉しい動画付き

やっぱり難しい・・・・・

「クリーンアーキテクチャを理解する」

を来年の目標としたいと思います。

では良いお年を!

10
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?