完全自分用の学習備忘録。
詳細な解説等はコード内でコメントアウトしております。
ToDoモデルとUserモデルを作成
-
models/todo.go
package models import ( "time" "gorm.io/gorm" ) type BasicModel struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` } type Todo struct { BasicModel Title string `gorm:"not null" json:"title"` IsCompleted bool `gorm:"not null;default:false" json:"is_completed"` // 複数の制約はセミコロンを使用 UserID uint `gorm:"not null" json:"user_id"` // リレーション設定 外部キーとして設定 (1対多) }
-
models/user.go
package models import "gorm.io/gorm" type User struct { gorm.Model Username string `gorm:"not null" json:"username"` Email string `gorm:"unique;not null" json:"email"` Password string `gorm:"not null" json:"password"` Todos []Todo `gorm:"constraint:OnDelete:CASCADE" json:"todos"` // リレーション設定 Userは複数のTodoを持つ }
マイグレーション実行ファイル
モデルの作成、修正後、以下のコマンドを実行してマイグレーションを実行
go run migrations/migration.go
-
migrations/migration.go
package main import ( "go-gin-gorm-riverpod-todo-app/infra" "go-gin-gorm-riverpod-todo-app/models" ) // モデルの作成、修正後、以下のコマンドを実行してマイグレーションを実行 // go run migrations/migration.go func main() { infra.Initialize() db := infra.SetupDB() if error := db.AutoMigrate(&models.Todo{}, &models.User{}); error != nil { panic("Failed to migrate database") } }
リクエストデータ受ける入れ物(DTO)を作成
flutterアプリからのリクエストデータを受け入れる入れ物(構造体)を用意
それぞれのフィールドにバリデーションを定義
-
dto/auth_dto.go
package dto type SignUpInput struct { Usermame string `json:"username" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` } type LoginInput struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` }
-
dto/auth_todo.go
package dto type CreateToDoInput struct { Title string `json:"title" binding:"required"` } // ポインタ型(*)でnullを許容 // omitnilでnilの場合はそのフィールドのバリデーションはしない type UpdateTodoInput struct { Title *string `json:"title" binding:"omitnil"` IsCompleted *bool `json:"is_completed" binding:"omitnil"` }
エントリーポイント(main関数)
以下の処理を実装する。
- .envファイルの読み込み処理
- DBの接続処理
- Repository → Service → Controller の順に依存性を注入
- APIエンドポイントを定義
3層アーキテクチャ構成
① Controller → リクエストデータのハンドリングやレスポンスの設定
② Service → 実現したい機能の実装(ビジネスロジック)
③ Repository → データの永続化やデータソースとのやりとり(メモリ上 or DB)
依存性の流れ
Controller → Service → Repository→ Database
Controller → Service
コントローラはリクエストを処理するが、ビジネスロジックはサービスに委ねる。依存性を注入することで、コントローラはサービスの具体的な実装に依存しない。
Service → Repository
サービスはビジネスロジックを管理するが、(メモリ or DB上の)データ操作はリポジトリに委ねる。依存性を注入することで、サービスはリポジトリの具体的な実装に依存しない。
Repository → Database
リポジトリはデータベース操作を管理する。外部(main関数)で生成したDBのインスタンスを依存性として注入する。
-
main.go
package main import ( "go-gin-gorm-riverpod-todo-app/controllers" "go-gin-gorm-riverpod-todo-app/infra" "go-gin-gorm-riverpod-todo-app/middlwares" // "go-gin-gorm-riverpod-todo-app/models" "go-gin-gorm-riverpod-todo-app/repositories" "go-gin-gorm-riverpod-todo-app/services" "github.com/gin-gonic/gin" "gorm.io/gorm" ) func setupRouter(db *gorm.DB) *gin.Engine { // メモリ上(NewTodoMemoryRepository)でデータ操作を行う場合、以下のサンプルデータを使用する /* // サンプルデータを作成 todos := []models.Todo{ {ID: 1, Title: "タイトル1", IsCompleted: false}, {ID: 2, Title: "タイトル2", IsCompleted: true}, {ID: 3, Title: "タイトル3", IsCompleted: false}, } */ // ファクトリ関数を用いてそれぞれ依存性を注入していく // 以下todoRespositoryを片方に切り替えることでControllerとServiceを修正することなく、容易にデータソースのやり取り先を変更できる // todoRepository := repositories.NewTodoMemoryRepository(todos) // → メモリ上でデータを操作 todoRepository := repositories.NewTodoRepository(db) // → DB上でデータを操作 todoService := services.NewTodoService(todoRepository) todoController := controllers.NewTodoController(todoService) authRepository := repositories.NewAuthRepository(db) authService := services.NewAuthService(authRepository) authController := controllers.NewAuthController(authService) r := gin.Default() // 共通のルートをグループ化 authRouter := r.Group("/auth") // AuthControllerでリクエストデータをハンドリングする前に、認証ミドルウェアを挟み、JWTToken認証を実施 todoRouterWithAuth := r.Group("/todos", middlwares.AuthMiddlware(authService)) authRouter.POST("/sign_up", authController.SignUp) // サインアップ authRouter.POST("/login", authController.Login) // ログイン todoRouterWithAuth.GET("", todoController.FindAll) // ログインしたユーザーに紐ずくtodoを全件取得 todoRouterWithAuth.POST("", todoController.Create) // todo新規作成 todoRouterWithAuth.PUT("/:id", todoController.Update) // todoの更新 todoRouterWithAuth.DELETE("/:id", todoController.Delete) // todoの削除 return r } // エントリーポイント func main() { // envファイルを読み込む infra.Initialize() // DBへの接続 db := infra.SetupDB() // APIルートを定義 r := setupRouter(db) r.Run("localhost:8080") }
サインアップ、ログイン処理(JWT認証)
-
repositories/auth_repository.go
package repositories import ( "errors" "go-gin-gorm-riverpod-todo-app/models" "gorm.io/gorm" ) type IAuthRepository interface { CreateUser(user models.User) error FindUser(email string) (*models.User, error) } type AuthRepository struct { db *gorm.DB } func NewAuthRepository(db *gorm.DB) IAuthRepository { return &AuthRepository{db: db} } func (r *AuthRepository) CreateUser(user models.User) error { result := r.db.Create(&user) if result.Error != nil { return result.Error } return nil } func (r *AuthRepository) FindUser(email string) (*models.User, error) { var user models.User result := r.db.First(&user, "email = ?", email) if result.Error != nil { if result.Error.Error() == "record not found" { return nil, errors.New("User not found") } return nil, result.Error } return &user, nil }
-
services/auth_service.go
package services import ( "fmt" "go-gin-gorm-riverpod-todo-app/models" "go-gin-gorm-riverpod-todo-app/repositories" "os" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) type IAuthService interface { SignUp(username string, email string, password string) (*string, error) Login(email string, password string) (*string, error) GetUserFromToken(tokenString string) (*models.User, error) } type AuthService struct { repository repositories.IAuthRepository } func NewAuthService(respository repositories.IAuthRepository) IAuthService { return &AuthService{repository: respository} } func (s *AuthService) SignUp(username string, email string, password string) (*string, error) { // パスワードのハッシュ化 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } user := models.User{ Username: username, Email: email, Password: string(hashedPassword), } createUserErr := s.repository.CreateUser(user) if createUserErr != nil { return nil, createUserErr } foundUser, err := s.repository.FindUser(email) if err != nil { return nil, err } // JWTTokenを生成 token, err := CreateToken(foundUser.ID, foundUser.Email) if err != nil { return nil, err } return token, nil } func (s *AuthService) Login(email string, password string) (*string, error) { foundUser, err := s.repository.FindUser(email) if err != nil { return nil, err } err = bcrypt.CompareHashAndPassword([]byte(foundUser.Password), []byte(password)) // パスワードが一致しない場合 if err != nil { return nil, err } token, err := CreateToken(foundUser.ID, foundUser.Email) if err != nil { return nil, err } return token, nil } func CreateToken(userId uint, email string) (*string, error) { // tokenの生成 // Claimsはtokenに含める様々な情報を指す // sub(subject)→ ユーザー識別子 // exp → tokenの有効期限 token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": userId, "email": email, "exp": time.Now().Add(time.Hour).Unix(), // 生成から1時間後に期限を設定 }) // .envファイルに定義した秘密鍵を使用して著名を行う tokenString, error := token.SignedString([]byte(os.Getenv("SECRET_KEY"))) if error != nil { return nil, error } return &tokenString, nil } // トークンに含まれる情報を基にユーザー情報を取得 func (s *AuthService) GetUserFromToken(tokenString string) (*models.User, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // トークンの署名を検証するコールバック関数内で、トークンの解析を行う // 署名がHMACか否か確認 if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) } return []byte(os.Getenv("SECRET_KEY")), nil }) if err != nil { return nil, err } var user *models.User // token.Claims: トークン内に含まれるデータ // クレームが正しい形式(MapClaims)であるか確認 if claims, ok := token.Claims.(jwt.MapClaims); ok { if float64(time.Now().Unix()) > claims["exp"].(float64) { return nil, jwt.ErrTokenExpired } // メールアドレスを基にデータベースからユーザー情報を取得 user, err = s.repository.FindUser(claims["email"].(string)) if err != nil { return nil, err } } return user, nil }
-
controllers/auth_controller.go
package controllers import ( "go-gin-gorm-riverpod-todo-app/dto" "go-gin-gorm-riverpod-todo-app/services" "net/http" "github.com/gin-gonic/gin" ) type IAuthController interface{ SignUp(ctx *gin.Context) Login(ctx *gin.Context) } type AuthController struct { service services.IAuthService } func NewAuthController(service services.IAuthService) IAuthController { return &AuthController{service: service} } // サインアップ func (c *AuthController) SignUp(ctx *gin.Context) { // リクエストデータを格納する変数を用意 var input dto.SignUpInput // リクエストデータをinput変数にバインド ここでリクエストデータのバリデーションが行わる if err := ctx.ShouldBindJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } token, err := c.service.SignUp(input.Usermame, input.Email, input.Password) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } // ネスト化したjsonレスポンスデータを返す ctx.JSON(http.StatusOK, gin.H{ "data": gin.H{ "username": input.Usermame, "email": input.Email, "password": input.Password, "jwt_token": token, // 生成したJWTToken }, }) } // ログイン func (c *AuthController) Login(ctx *gin.Context) { // リクエストデータを格納する変数を用意 var input dto.LoginInput // リクエストデータをinput変数にバインド ここでリクエストデータのバリデーションが行わる if err := ctx.ShouldBindJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } token, err := c.service.Login(input.Email, input.Password) if err != nil { if err.Error() == "User not found" { ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // ネスト化したjsonレスポンスデータを返す ctx.JSON(http.StatusOK, gin.H{ "data": gin.H{ "username": "", "email": input.Email, "password": input.Password, "jwt_token": token, // 生成したJWTToken }, }) }
認証ミドルウェア
ミドルウェアでJWT認証を行い、リクエストコンテキストにユーザー情報をセットする
-
middlwares/auth_middlware
package middlwares import ( "go-gin-gorm-riverpod-todo-app/services" "net/http" "strings" "github.com/gin-gonic/gin" ) // gin.HandlerFunc型を返す→ type HandlerFunc func(*Context) func AuthMiddlware(authService services.IAuthService) gin.HandlerFunc { return func(ctx *gin.Context) { header := ctx.GetHeader("Authorization") // Authorizationヘッダー取得 if header == "" { ctx.AbortWithStatus(http.StatusUnauthorized) return } // Authorizationヘッダーが、「"Bearer "」で始まることを確認 if !strings.HasPrefix(header, "Bearer ") { ctx.AbortWithStatus(http.StatusUnauthorized) return } // 文字列 header から先頭にある "Bearer " を取り除いて、トークン部分だけを取得 tokenString := strings.TrimPrefix(header, "Bearer ") // トークンに含まれる情報を基にユーザー情報を取得 user, error := authService.GetUserFromToken(tokenString) if error != nil { ctx.AbortWithStatus(http.StatusUnauthorized) return } // ユーザー情報をリクエストコンテキストにセット("user"キーで値を取り出す) ctx.Set("user", user) // 処理フローを次のmiddlwareまたは、目的の処理に移す ctx.Next() } }
TodoのCRUD処理
以下の機能を実装する。
-
新規作成
-
更新
-
全件取得
-
削除
-
repositories/to_do_repository.go
package repositories import ( "errors" "go-gin-gorm-riverpod-todo-app/models" "gorm.io/gorm" ) type ITodoRepository interface { FindAll(userId uint) (*[]models.Todo, error) FindById(todoId uint, userId uint) (*models.Todo, error) Create(newTodo models.Todo) (*models.Todo, error) Update(updateTodo models.Todo) (*models.Todo, error) Delete(todoId uint, userId uint) error } type TodoMemoryRepository struct { todos []models.Todo } func NewTodoMemoryRepository(todos []models.Todo) ITodoRepository { return &TodoMemoryRepository{todos: todos} } func (r *TodoMemoryRepository) FindAll(userId uint) (*[]models.Todo, error) { return &r.todos, nil } func (r *TodoMemoryRepository) FindById(todoId uint, userId uint) (*models.Todo, error) { for _, v := range r.todos { if v.ID == todoId { return &v, nil } } return nil, errors.New("Todo not found") } func (r *TodoMemoryRepository) Create(newItem models.Todo) (*models.Todo, error) { newItem.ID = uint(len(r.todos) + 1) r.todos = append(r.todos, newItem) return &newItem, nil } func (r *TodoMemoryRepository) Update(updateTodo models.Todo) (*models.Todo, error) { for i, v := range r.todos { if v.ID == updateTodo.ID { r.todos[i] = updateTodo return &r.todos[i], nil } } return nil, errors.New("Unexpected error") } func (r *TodoMemoryRepository) Delete(todoId uint, userId uint) error { for i, v := range r.todos { if v.ID == todoId { r.todos = append(r.todos[:i], r.todos[i+1:]...) return nil } } return errors.New("Todo not found") } // ↑ メモリ上でデータを操作 // ================================================================================================================================================================================= // ↓ DBを用いてデータ操作 type TodoRepository struct { db *gorm.DB } // ファクトリ関数 func NewTodoRepository(db *gorm.DB) ITodoRepository { // ToDoRepository構造体がITodoRepositoryインターフェースを満たしており、ITodoRepositoryは具体的な値の型情報とポインタ情報を持つ return &TodoRepository{db: db} } // ===== ToDoRepository構造体がITodoRepositoryインターフェースを満たすようにインターフェースに定義されたメソッドを実装する ===== // 新規作成 func (r *TodoRepository) Create(newTodo models.Todo) (*models.Todo, error) { result := r.db.Create(&newTodo) // 参照を渡す if result.Error != nil { return nil, result.Error } return &newTodo, nil } // 削除 func (r *TodoRepository) Delete(todoId uint, userId uint) error { deleteTodo, error := r.FindById(todoId, userId) if error != nil { return error } result := r.db.Delete(&deleteTodo) if result.Error != nil { return result.Error } return nil // 削除成功でnilを返す } // 全件取得 func (r *TodoRepository) FindAll(userId uint) (*[]models.Todo, error) { var todos []models.Todo result := r.db.Find(&todos, "user_id = ?", userId) if result.Error != nil { return nil, result.Error } return &todos, nil } // idに一致するデータを取得 func (r *TodoRepository) FindById(todoId uint, userId uint) (*models.Todo, error) { // models.Todo型の値を格納する変数を定義 var todo models.Todo // Fisrt()で最初にヒットした一件のみ取得 // 第一引数には検索結果を格納するモデルの参照、第二引数には検索条件を渡す result := r.db.First(&todo, "id = ? AND user_id = ?", todoId, userId) if result.Error != nil { if result.Error.Error() == "record not found" { return nil, errors.New("Todo not found") } return nil, result.Error } return &todo, nil } // 更新 func (r *TodoRepository) Update(updateTodo models.Todo) (*models.Todo, error) { result := r.db.Save(&updateTodo) if result.Error != nil { return nil, result.Error } return &updateTodo, nil }
-
services/to_do_service.go
package services import ( "go-gin-gorm-riverpod-todo-app/dto" "go-gin-gorm-riverpod-todo-app/models" "go-gin-gorm-riverpod-todo-app/repositories" ) type ITodoService interface { FindAll(userId uint) (*[]models.Todo, error) FindById(todoId uint, userId uint) (*models.Todo, error) Create(createTodoInput dto.CreateToDoInput, userId uint) (*models.Todo, error) Update(todoId uint, userId uint, updateItemInput dto.UpdateTodoInput) (*models.Todo, error) Delete(todoId uint, userId uint) error } type TodoService struct { repository repositories.ITodoRepository } // ファクトリ関数 func NewTodoService(repository repositories.ITodoRepository) ITodoService { // ITodoRepositoryは代入される具体的な値の型情報とポインタ情報を持つ // TodoService構造体はITodoServiceインターフェースを満たしており、ITodoServiceは具体的な値の型情報とポインタ情報を持つ return &TodoService{repository: repository} } // ===== TodoService構造体がITodoServiceインターフェースを満たすようにインターフェースに定義されたメソッドを実装する ===== // 全件取得 func (s *TodoService) FindAll(userId uint) (*[]models.Todo, error) { return s.repository.FindAll(userId) } // idに一致するデータを取得 func (s *TodoService) FindById(todoId uint, userId uint) (*models.Todo, error) { return s.repository.FindById(todoId, userId) } // 新規作成 func (s *TodoService) Create(createTodoInput dto.CreateToDoInput, userId uint) (*models.Todo, error) { newTodo := models.Todo{ Title: createTodoInput.Title, IsCompleted: false, UserID: userId, } return s.repository.Create(newTodo) } // 更新 func (s *TodoService) Update(todoId uint, userId uint, updateTodoInput dto.UpdateTodoInput) (*models.Todo, error) { targetItem, error := s.FindById(todoId, userId) if error != nil { return nil, error } // 対象のtodoのタイトルを更新 if updateTodoInput.Title != nil { targetItem.Title = *updateTodoInput.Title } // 対象のtodoの完了フラグを更新 if updateTodoInput.IsCompleted != nil { targetItem.IsCompleted = *updateTodoInput.IsCompleted } return s.repository.Update(*targetItem) } // 削除 func (s *TodoService) Delete(todoId uint, userId uint) error { return s.repository.Delete(todoId, userId) }
-
controllers/to_do_controller.go
package controllers import ( "go-gin-gorm-riverpod-todo-app/dto" "go-gin-gorm-riverpod-todo-app/models" "go-gin-gorm-riverpod-todo-app/services" "net/http" "strconv" "github.com/gin-gonic/gin" ) type ITodoController interface { FindAll(ctx *gin.Context) Create(ctx *gin.Context) Update(ctx *gin.Context) Delete(ctx *gin.Context) } type TodoController struct { service services.ITodoService } func NewTodoController(service services.ITodoService) ITodoController { // ITodoServiceは代入される具体的な値の型情報とポインタ情報を持つ // TodoController構造体はITodoControllerインターフェースを満たしており、ITodoServiceは具体的な値の型情報とポインタ情報を持つ return &TodoController{service: service} } // ===== TodoController構造体がITodoControllerインターフェースを満たすようにインターフェースに定義されたメソッドを実装する ===== // 全件取得 func (c *TodoController) FindAll(ctx *gin.Context) { user, exists := ctx.Get("user") // AuthMiddlewareにてSet()で設定した"user"キーで値を取り出す if !exists { ctx.AbortWithStatus(http.StatusUnauthorized) return } // userはany型のため、型アサーションを行う userId := user.(*models.User).ID todos, err := c.service.FindAll(userId) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Unexpected error"}) return } // ネスト化したjsonレスポンスデータを送る ctx.JSON(http.StatusOK, gin.H{ "data": gin.H{ "todos": todos, }, }) } // 新規作成 func (c *TodoController) Create(ctx *gin.Context) { user, exists := ctx.Get("user") // AuthMiddlewareにてSet()で設定した"user"キーで値を取り出す if !exists { ctx.AbortWithStatus(http.StatusUnauthorized) return } // userはany型のため、型アサーションを行う userId := user.(*models.User).ID // リクエストデータを受け取る変数を用意 var input dto.CreateToDoInput // リクエストデータをinput変数にバインド ここでリクエストデータのバリデーションが行われる if err := ctx.ShouldBindJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } newTodo, err := c.service.Create(input, userId) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusCreated, gin.H{"data": newTodo}) } func (c *TodoController) Update(ctx *gin.Context) { user, exists := ctx.Get("user") // AuthMiddlewareにてSet()で設定した"user"キーで値を取り出す if !exists { ctx.AbortWithStatus(http.StatusUnauthorized) return } // userはany型のため、型アサーションを行う userId := user.(*models.User).ID todoId, err := strconv.ParseInt(ctx.Param("id"), 10, 64) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id"}) return } // リクエストデータを受け取る変数を用意 var input dto.UpdateTodoInput // リクエストデータをinput変数にバインド ここでリクエストデータのバリデーションが行われる if err := ctx.ShouldBindJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } updatedTodo, err := c.service.Update(uint(todoId), userId, input) if err != nil { if err.Error() == "Todo not found" { ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Unexpected error"}) return } ctx.JSON(http.StatusOK, gin.H{"data": updatedTodo}) } func (c *TodoController) Delete(ctx *gin.Context) { user, exists := ctx.Get("user") // AuthMiddlewareにてSet()で設定した"user"キーで値を取り出す if !exists { ctx.AbortWithStatus(http.StatusUnauthorized) return } // userはany型のため、型アサーションを行う userId := user.(*models.User).ID todoId, error := strconv.ParseInt(ctx.Param("id"), 10, 64) if error != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id"}) return } error = c.service.Delete(uint(todoId), userId) if error != nil { if error.Error() == "Item not found" { ctx.JSON(http.StatusNotFound, gin.H{"error": error.Error()}) return } ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Unexpected error"}) return } ctx.JSON(http.StatusOK, gin.H{"data": nil}) }