はじめに
今回はGo言語での開発におけるMVCモデルについて考えてみました。
過去記事で作成したプロジェクトをMVCモデルに置き換えてみます。
MVCとは
Model, View, Controllerの頭文字を取ってMVCです。
Modelはビジネスロジックを担当し、ViewはHTMLなどユーザーが確認できる部分を担当し、ControllerはModelとViewの橋渡しを担当します。
検証環境
構成について考えるにあたり、過去の記事をベースに進めます。
ただし、今回は構成を見直すにあたって必然的にソースの改修も行っているので、参考になるのはGo開発環境の構築までかもしれません。
ですので、ひとまずはGo-Gin-GORM開発環境を作り、JWTのライブラリ(jwt-go
)を導入してもらえれば良いかと思います。
実行条件
こちらの構成で動作確認をしています。
Golang v1.22.3
Gin v1.10.0
GORM v1.25.10
jwt-go v3.2.0
MySQL 8.0.29
ディレクトリ構成
最終的にはこうなります。
.
├── .air.toml
├── Dockerfile
├── controllers
│ ├── login_controller.go
│ └── token_valid_controller.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── libraries
│ └── crypto
│ └── crypto.go
├── main.go
├── models
│ ├── database.go
│ └── users.go
├── routes
│ └── api_route.go
├── services
│ ├── access_token_validation.go
│ └── login.go
├── tmp
└── views
MVCについて考える
ルーティング
まずはルーティング設定からです。
過去の記事で作成したサインアップ、サインイン、アクセストークン検証のルーティング設定を行います。
package main
import (
"go_gin_gorm/routes"
)
func main() {
router := routes.GetApiRouter()
router.Run(":8080")
}
main.go
はシンプルです。ルーティング設定を定義した処理を呼び出すだけです。
package routes
import (
"github.com/gin-gonic/gin"
"go_gin_gorm/controllers"
)
func GetApiRouter() *gin.Engine {
r := gin.Default()
// r.LoadHTMLGlob("view/*html")
r.POST("/signup", controllers.SignUp)
r.POST("/signin", controllers.SignIn)
r.GET("/tokenvalid", controllers.TokenValid)
return r
}
api_route.go
にAPIのルーティング設定を記載しています。Viewについては本書では扱わなかったので (MCか)、ここではコメントアウトしています。
メソッドの第一引数がエンドポイント、第二引数が呼び出し関数となります。
ライブラリ(ユーティリティ)
共通で利用できる処理をこちらにまとめました。
(といっても現時点では暗号化処理しかありませんが…)
package crypto
import (
"golang.org/x/crypto/bcrypt"
)
// 暗号(Hash)化
func PasswordEncrypt(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
// 暗号(Hash)と入力された平パスワードの比較
func CompareHashAndPassword(hash, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
内容は過去記事のものと同じです。
モデル
データベースアクセスを行う処理はこちらにまとめました。
また、せっかくなのでDBへの接続を共通化しました。
package models
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var Db *gorm.DB
func init() {
Db = Connect()
}
func Connect() *gorm.DB {
//DB接続
dsn := "root@tcp(mysql:3306)/first?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
return db
}
database.go
のinit関数でDB接続を行います。
余談ですが、initは直接呼び出す必要がなく、main関数よりも先に自動的に実行されます。
package models
type User struct {
Id int
UserId string
Password string
}
// 1件取得用
func GetOneUsers(userId string) (data User) {
Db.Where("user_id = ?", userId).First(&data)
return
}
// 1件登録用
func RegistUser(userId string, encryptPw string) {
user := User{}
user = User{UserId: userId, Password: encryptPw}
Db.Create(&user)
}
こちらはusersテーブルを操作する処理をまとめたものがこちらです(といってもメソッドは1つだけですが…)。
DB接続を別処理に切り出したため、非常にシンプルになっています。
サービス
ビジネスロジックにあたります。今回はDB操作を行う処理とそれ以外の処理を分けたかったのでこのようにしてみました。
サービスではユーザーからのリクエストを受けて諸々(大味説明)の処理を行います。
package services
import (
"go_gin_gorm/models"
"go_gin_gorm/libraries/crypto"
"time"
"github.com/dgrijalva/jwt-go"
"fmt"
)
func SignUp(userId string, password string) (error) {
// ユーザー情報の取得
data := models.GetOneUsers(userId)
if data.Id != 0 {
return fmt.Errorf("そのUserIdは既に登録されています。")
}
// パスワードの暗号化
encryptPw, err := crypto.PasswordEncrypt(password)
if err != nil {
return fmt.Errorf("パスワードの暗号化でエラーが発生しました。")
}
// DB登録
models.RegistUser(userId, encryptPw)
return nil
}
func SignIn(userId string, password string) (string, error) {
// ユーザー情報の取得
data := models.GetOneUsers(userId)
if data.Id == 0 {
return "", fmt.Errorf("ユーザーが存在しません。")
}
// パスワードの検証
err := crypto.CompareHashAndPassword(data.Password, password)
if err != nil {
return "", fmt.Errorf("パスワードが一致しません。")
}
// JWTに付与する構造体
var limit time.Duration = time.Hour * 24 // トークンの有効期限を24時間とする
claims := jwt.MapClaims{
"user_id": userId,
"password": password,
"exp": time.Now().Add(limit).Unix(),
}
// ヘッダーとペイロード生成
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// トークンに署名を付与
accessToken, _ := token.SignedString([]byte("ACCESS_SECRET_KEY"))
return accessToken, nil
}
ログイン機能(サインアップ、サインイン)をまとめた処理がこちらです。
呼び出し元から受け取ったユーザーIDとパスワードをもとに、DBに問い合わせを行い、その結果を受けて処理を行います。
サインイン処理(SignIn)ではアクセストークンの生成を行っていますが、これは共通処理に移してしまっても良いかもしれません。
package services
import (
"go_gin_gorm/models"
"go_gin_gorm/libraries/crypto"
"github.com/dgrijalva/jwt-go"
"fmt"
"time"
)
var Layout = "2006-01-02 15:04:05"
func AccessTokenValidation(access_token string) (string, error) {
token, err := jwt.Parse(access_token, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte("ACCESS_SECRET_KEY"), nil
})
// エラーチェック
if err != nil {
var msg string;
// 妥当性チェックエラー判定
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&jwt.ValidationErrorExpired != 0 {
msg = "アクセストークンの有効期限が切れています。"
} else {
msg = "有効期限切れ以外の妥当性チェックエラーです。"
}
} else {
msg = "妥当性チェック以外のエラー。"
}
return "", fmt.Errorf(msg)
}
// クレームの取得
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userId := string(claims["user_id"].(string))
pw := string(claims["password"].(string) )
exp := int64(claims["exp"].(float64))
// ユーザー情報の取得
data := models.GetOneUsers(userId)
if data.Id == 0 {
return "", fmt.Errorf("ユーザーが存在しません。")
}
// パスワードの検証
err := crypto.CompareHashAndPassword(data.Password, pw)
if err != nil {
return "", fmt.Errorf("パスワードが一致しません。%s",pw)
}
return "トークンの検証に成功しました 。 有効期限 : " + time.Unix(exp, 0).Format(Layout), nil
} else {
return "", fmt.Errorf("クレームの取得に失敗しました。")
}
return "", nil
}
こちらはアクセストークンを検証するための処理です。
呼び出し元から受け取ったアクセストークンを解析し、その結果に基づいて処理を行います。
コントローラ
ユーザーから受け取った情報(リクエスト)を精査してビジネスロジックに渡す処理がこちらです。
ビジネスロジックでの結果に応じて返却情報(レスポンス)も生成しています。
package controllers
import (
"github.com/gin-gonic/gin"
"net/http"
"go_gin_gorm/services"
)
type JsonRequest struct {
UserId string `json:"user_id"`
Password string `json:"password"`
}
func SignUp(c *gin.Context) {
// リクエストの解析
var json JsonRequest
if err := c.ShouldBindJSON(&json); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error" : err.Error(),
})
return
}
userId := json.UserId
pw := json.Password
// サインアップ処理の実行
if err := services.SignUp(userId, pw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error" : err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message" : "サインアップ成功",
})
}
func SignIn(c *gin.Context) {
// リクエストの解析
var json JsonRequest
if err := c.ShouldBindJSON(&json); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error" : err.Error(),
})
return
}
userId := json.UserId
pw := json.Password
// ログイン処理の実行
token, err := services.SignIn(userId, pw);
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error" : err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message" : "ログイン成功",
"accessToken" : token,
})
}
ログイン機能に関連したエンドポイントの処理はこちらで行っています。
リクエストはjson形式で受け取るため、コントローラではjsonから必要な情報を取得してビジネスロジックに渡しています。
package controllers
import (
"github.com/gin-gonic/gin"
"net/http"
"go_gin_gorm/services"
"strings"
)
func TokenValid(c *gin.Context) {
// アクセストークン取得
access_token := GetAccessToken(c)
if access_token == "" {
MakeJson(c, http.StatusUnauthorized, "Bearerが存在しません。")
return;
}
// アクセストークン検証
msg, err := services.AccessTokenValidation(access_token)
if err != nil {
MakeJson(c, http.StatusUnauthorized, err.Error())
return;
}
MakeJson(c, http.StatusOK, msg)
return;
}
// 返却用Json作成
func MakeJson(c *gin.Context, code int, msg string) {
c.JSON(code, gin.H{
"message" : msg,
})
}
// Bearerからアクセストークン取得
func GetAccessToken(c *gin.Context) string {
authorizationHeader := c.Request.Header.Get("Authorization")
if authorizationHeader != "" {
ary := strings.Split(authorizationHeader, " ")
if len(ary) == 2 {
// Bearer値を解析する
if ary[0] == "Bearer" {
return ary[1]
}
}
}
return ""
}
アクセストークン検証のエンドポイントの処理はこちらで行っています。
アクセストークンはAuthorizationリクエストヘッダのBearerに含まれるため、これを取り出しています。
今回は"そういうサービス"としているため取り出したアクセストークンはビジネスロジックに渡して任せていますが、アクセストークン解析は本来ほかのサービスでも利用前に必ず行われるはずなので、共通化にしても良いかと思います。
動作確認
エラーがなければ動作確認をしてみましょう。
curl -X POST "http://localhost:8080/signup" -H "Content-Type: application/json" -d '{"user_id": "mvc_test_user", "password": "Password_"}'
{
"message": "サインアップ成功"
}
curl -X POST "http://localhost:8080/signin" -H "Content-Type: application/json" -d '{"user_id": "mvc_test_user", "password": "Password_"}'
{
"accessToken": "★長い文字列★",
"message": "ログイン成功"
}
curl -X GET "http://localhost:8080/tokenvaid" -H 'Authorization: Bearer ★サインインのレスポンスのaccessToken値★'
{
"message": "トークンの検証に成功しました 。 有効期限 : 2024-05-28 03:18:59"
}
まとめ
いかがだったでしょうか。
正解系がどんな感じなのかは正直わかっていませんが、少なくともそれぞれの処理の役割が分かれたことで以前よりは読みやすくなったのではないかと思います。