保険
この記事はGoを勉強して1ヶ月くらいの奴が書いてます。
また、クリーンアーキテクチャについてわかったつもりのやつが書いてます。
ご了承ください。
機能
- ユーザー登録
- ログイン
- ログアウト
クリーンアーキテクチャ
ログイン処理の流れ
理解を簡単にするために、ユーザーがログインする際の実際の処理の流れについて考えてみます。
- ユーザーがエンドポイント
/login
にPOSTリクエスト - ルーターが
/login
に対応するコントローラーを呼ぶ - コントローラーがリクエストボディのjsonを構造体に変換してユースケースに渡す
- ユースケースが渡された構造体の
Email
を引数にリポジトリを呼び出す - リポジトリがDBにアクセスし、
Email
が一致するレコードをSELECTし、構造体に変換してユースケースに渡す - ユースケースが
Email
とPassword
が一致するか判定し、一致したらトークンを発行 - コントローラーがトークンをjsonに変換してレスポンスを返す
各レイヤーの役割
router
-
POST /signup
でコントローラーのSignup
関数を呼ぶ -
POST /login
でコントローラーのLogin
関数を呼ぶ -
POST /logout
でコントローラーのLogout
関数を呼ぶ
controller
-
Signup
:リクエストのbodyのjsonを構造体に変換してユースケースのSignup
関数に渡し、結果をjsonで返す -
Login
:リクエストのbodyのjsonを構造体に変換してユースケースのLogin
関数に渡し、ログインに成功した場合はクッキーにログイン情報を保存 -
Logout
:クッキーのログイン情報を削除
usecase
-
Signup
:User構造体のパスワードをハッシュ化し、リポジトリのCreateUser
に渡す -
Login
:Email
フィールドを引数としてリポジトリのGetByEmail
を呼び出し、Email
とPassword
を照合し、一致したらトークンを生成してコントローラーに返す
repository
-
CreateUser
:DBにユーザー情報をINSERTする -
GetByEmail
:Email
フィールドが一致するレコードをSELECTし構造体に変換してユースケースに返す
request / response
今回実装するAPIのリクエスト・レスポンスの例です。
POST /signup
{
"email": "hogehoge1@example.com"
"password": "hoge1"
}
{
"ID": 1
"email": "hogehoge1@example.com"
}
POST /login
{
"email": "hogehoge1@example.com"
"password": "hoge1"
}
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDExMjc5MTgsInVzZXJfaWQiOjF9.Tp5tUkrLCNhxJnDc7Z0a4UfqCcsF22jEqbheeRn82E0"
}
実装
entity
ここではUser
構造体を定義します。主キーのID
と、登録に必要なEmail
とPassword
です。
package entity
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"unique"`
Password string `json:"password"`
}
Email
には重複がないようにunique制約をつけています。
ここで、一番内側のentity層にgorm
の情報が書かれているのは厳密には良くないのではと思いましたが、どうなんでしょう。
main
package main
import (
"gin-login/controller"
"gin-login/db"
"gin-login/repository"
"gin-login/router"
"gin-login/usecase"
)
func main() {
db := db.NewDB()
userRepository := repository.NewUserRepository(db)
userUsecase := usecase.NewUserUsecase(userRepository)
userController := controller.NewUserController(userUsecase)
r := router.NewRouter(userController)
r.Run()
}
router
特にいうことはないです。
package router
import (
"gin-login/controller"
"github.com/gin-gonic/gin"
)
func NewRouter(uc controller.UserController) *gin.Engine {
r := gin.Default()
r.POST("/signup", uc.Signup)
r.POST("/login", uc.Login)
r.POST("/logout", uc.Logout)
return r
}
controller
Signup
Login
Logout
の3つのメソッドを持つインターフェースを定義し、実装しています。
ログイン情報はCookieに保存しています。
package controller
import (
"gin-login/entity"
"gin-login/usecase"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type UserController interface {
Signup(c *gin.Context)
Login(c *gin.Context)
Logout(c *gin.Context)
}
type userController struct {
uu usecase.UserUsecase
}
func NewUserController(uu usecase.UserUsecase) UserController {
return &userController{uu}
}
func (uc *userController) Signup(c *gin.Context) {
user := entity.User{}
if err := c.BindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, err)
}
err := uc.uu.Signup(user)
if err != nil {
c.JSON(http.StatusInternalServerError, err)
}
c.JSON(http.StatusOK, nil)
}
func (uc *userController) Login(c *gin.Context) {
user := entity.User{}
if err := c.BindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, err)
}
tokenString, err := uc.uu.Login(user)
if err != nil {
c.JSON(http.StatusInternalServerError, err)
}
cookie := &http.Cookie{
Name: "token",
Value: tokenString,
Expires: time.Now().Add(1 * time.Hour),
Path: "/",
Domain: "localhost",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteNoneMode,
}
http.SetCookie(c.Writer, cookie)
c.JSON(http.StatusOK, tokenString)
}
func (uc *userController) Logout(c *gin.Context) {
cookie := &http.Cookie{
Name: "token",
Value: "",
Expires: time.Now(),
Path: "/",
Domain: "localhost",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteNoneMode,
}
http.SetCookie(c.Writer, cookie)
c.JSON(http.StatusOK, nil)
}
usecase
Signup
Login
の2つのメソッドを持つインターフェースを定義し、実装しています。
ログインに成功した場合はトークンを生成します。
package usecase
import (
"gin-login/entity"
"gin-login/repository"
"os"
"time"
"github.com/golang-jwt/jwt/v4"
"golang.org/x/crypto/bcrypt"
)
type UserUsecase interface {
Signup(user entity.User) error
Login(user entity.User) (string, error)
}
type userUsecase struct {
ur repository.UserRepository
}
func NewUserUsecase(ur repository.UserRepository) UserUsecase {
return &userUsecase{ur}
}
func (uu *userUsecase) Signup(user entity.User) error {
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10)
if err != nil {
return err
}
newUser := entity.User{Email: user.Email, Password: string(hash)}
if err := uu.ur.CreateUser(&newUser); err != nil {
return err
}
return nil
}
func (uu *userUsecase) Login(user entity.User) (string, error) {
storedUser := entity.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
}
repository
リポジトリは、DBへの読み書きを行います。
2つのメソッド
GetUserByEmail
CreateUser
を持つインターフェースを定義し、それらを実装していきます。
package repository
import (
"gin-login/entity"
"gorm.io/gorm"
)
type UserRepository interface {
GetUserByEmail(user *entity.User, email string) error
CreateUser(user *entity.User) error
}
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db}
}
func (ur *userRepository) GetUserByEmail(user *entity.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 *entity.User) error {
if err := ur.db.Create(user).Error; err != nil {
return err
}
return nil
}
GetUserByEmail(user *entity.User, email string) error
空のUser構造体へのポインタuser
とstring型のemail
を受け取り、合致するレコードを構造体に変換してuser
へ格納します。Gormを使っているので構造体への変換は勝手にやってくれます。エラーが起きた場合はerrorをそのまま返します。
CreateUser(user *entity.User) error
ユーザーから入力されたEmail
とPassword
を持つUser構造体のポインタを受け取り、DBへINSERTします。エラーが起きた場合はerrorをそのまま返します。