快速搭建一個go語言web後端服務腳手架

2023-04-12 15:00:47

快速搭建一個go語言web後端服務腳手架
原始碼:https://github.com/weloe/go-web-demo

web框架使用gin,資料操作使用gorm,存取控制使用casbin

首先新增一下自定義的middleware

recover_control.go ,統一處理panic error返回的資訊

package middleware

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"go-web-demo/component"
	"log"
	"net/http"
)

func Recover(c *gin.Context) {
	defer func() {
		if r := recover(); r != nil {
			// print err msg
			log.Printf("panic: %v\n", r)
			// debug.PrintStack()
			// response same struct
			c.JSON(http.StatusBadRequest, component.RestResponse{Code: -1, Message: fmt.Sprintf("%v", r)})
		}
	}()

	c.Next()
}

access_control.go 使用casbin進行存取控制的中介軟體

package middleware

import (
	"fmt"
	"github.com/casbin/casbin/v2"
	gormadapter "github.com/casbin/gorm-adapter/v3"
	"github.com/gin-gonic/gin"
	_ "github.com/go-sql-driver/mysql"
	"go-web-demo/component"
	"log"
	"net/http"
)

// DefaultAuthorize determines if current subject has been authorized to take an action on an object.
func DefaultAuthorize(obj string, act string) gin.HandlerFunc {
	return func(c *gin.Context) {

		// Get current user/subject
		token := c.Request.Header.Get("token")
		if token == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, component.RestResponse{Message: "token is nil"})
			return
		}
		username, err := component.GlobalCache.Get(token)
		if err != nil || string(username) == "" {
			log.Println(err)
			c.AbortWithStatusJSON(http.StatusUnauthorized, component.RestResponse{Message: "user hasn't logged in yet"})
			return
		}

		// Casbin enforces policy
		ok, err := enforce(string(username), obj, act, component.Enforcer)
		if err != nil {
			log.Println(err)
			c.AbortWithStatusJSON(http.StatusInternalServerError, component.RestResponse{Message: "error occurred when authorizing user"})
			return
		}
		if !ok {
			c.AbortWithStatusJSON(http.StatusForbidden, component.RestResponse{Message: "forbidden"})
			return
		}

		c.Next()
	}
}

func enforce(sub string, obj string, act string, enforcer *casbin.Enforcer) (bool, error) {
	// Load policies from DB dynamically
	err := enforcer.LoadPolicy()
	if err != nil {
		return false, fmt.Errorf("failed to load policy from DB: %w", err)
	}
	// Verify
	ok, err := enforcer.Enforce(sub, obj, act)
	return ok, err
}

func AuthorizeAdapterAndModel(obj string, act string, adapter *gormadapter.Adapter, model string) gin.HandlerFunc {
	return func(c *gin.Context) {

		// Get current user/subject
		token := c.Request.Header.Get("token")
		if token == "" {
			c.AbortWithStatusJSON(401, component.RestResponse{Message: "token is nil"})
			return
		}
		username, err := component.GlobalCache.Get(token)
		if err != nil || string(username) == "" {
			log.Println(err)
			c.AbortWithStatusJSON(401, component.RestResponse{Message: "user hasn't logged in yet"})
			return
		}

		// Load model configuration file and policy store adapter
		enforcer, err := casbin.NewEnforcer(model, adapter)
		// Casbin enforces policy
		ok, err := enforce(string(username), obj, act, enforcer)

		if err != nil {
			log.Println(err)
			c.AbortWithStatusJSON(500, component.RestResponse{Message: "error occurred when authorizing user"})
			return
		}
		if !ok {
			c.AbortWithStatusJSON(403, component.RestResponse{Message: "forbidden"})
			return
		}

		c.Next()
	}
}

reader.go 讀取yaml組態檔的根據類,使用了viter

package config

import (
	"fmt"
	"github.com/spf13/viper"
	"log"
	"sync"
	"time"
)

type Config struct {
	Server     *Server
	Mysql      *DB
	LocalCache *LocalCache
	Casbin     *Casbin
}

type Server struct {
	Port int64
}

type DB struct {
	Username string
	Password string
	Host     string
	Port     int64
	Dbname   string
	TimeOut  string
}

type LocalCache struct {
	ExpireTime time.Duration
}

type Casbin struct {
	Model string
}

var (
	once   sync.Once
	Reader = new(Config)
)

func (config *Config) ReadConfig() *Config {
	once.Do(func() {
		viper.SetConfigName("config")   // filename
		viper.SetConfigType("yaml")     // filename extension : yaml | json |
		viper.AddConfigPath("./config") // workspace dir : ./
		var err error
		err = viper.ReadInConfig() // read config
		if err != nil {            // handler err
			log.Fatalf(fmt.Sprintf("Fatal error config file: %s \n", err))
		}
		err = viper.Unmarshal(config)
		if err != nil {
			log.Fatalf(fmt.Sprintf("Fatal error viper unmarshal config: %s \n", err))
		}
	})
	return Reader
}

組態檔

server:
  port: 8080

mysql:
  username: root
  password: pwd
  host: 127.0.0.1
  port: 3306
  dbname: casbin_demo
  timeout: 10s

localCache:
  expireTime: 60

casbin:
  model: config/rbac_model.conf

persistence.go, gorm,bigcache, casbin 初始化,這裡用的casbin是從資料庫讀取policy

package component

import (
	"fmt"
	"github.com/allegro/bigcache"
	"github.com/casbin/casbin/v2"
	gormadapter "github.com/casbin/gorm-adapter/v3"
	_ "github.com/go-sql-driver/mysql"
	"go-web-demo/config"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"log"
	"time"
)

var (
	DB          *gorm.DB
	GlobalCache *bigcache.BigCache
	Enforcer    *casbin.Enforcer
)

// CreateByConfig create components
func CreateByConfig() {

	ConnectDB()

	CreateLocalCache()

	CreateCasbinEnforcer()
}

func ConnectDB() {
	// connect to DB
	var err error
	dbConfig := config.Reader.ReadConfig().Mysql
	if dbConfig == nil {
		log.Fatalf(fmt.Sprintf("db config is nil"))
	}
	// config
	username := dbConfig.Username
	password := dbConfig.Password
	host := dbConfig.Host
	port := dbConfig.Port
	Dbname := dbConfig.Dbname
	timeout := dbConfig.TimeOut

	dbUrl := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=True&loc=Local&timeout=%s", username, password, host, port, Dbname, timeout)
	log.Println("connect db url: " + dbUrl)
	DB, err = gorm.Open(mysql.Open(dbUrl), &gorm.Config{})

	if err != nil {
		log.Fatalf(fmt.Sprintf("failed to connect to DB: %v", err))
	}
}

func CreateLocalCache() {
	var err error
	cacheConfig := config.Reader.ReadConfig().LocalCache
	if cacheConfig == nil {
		log.Fatalf(fmt.Sprintf("cache config is nil"))
	}
	// Initialize cache to store current user in cache.
	GlobalCache, err = bigcache.NewBigCache(bigcache.DefaultConfig(cacheConfig.ExpireTime * time.Second)) // Set expire time to 30 s
	if err != nil {
		log.Fatalf(fmt.Sprintf("failed to initialize cahce: %v", err))
	}
}

func CreateCasbinEnforcer() {
	var err error

	// casbin model
	config := config.Reader.ReadConfig().Casbin
	if config == nil {
		log.Fatalf(fmt.Sprintf("casbin config is nil"))
	}
	model := config.Model
	//Initialize casbin adapter
	adapter, _ := gormadapter.NewAdapterByDB(DB)

	// Load model configuration file and policy store adapter
	Enforcer, err = casbin.NewEnforcer(model, adapter)
	if err != nil {
		log.Fatalf(fmt.Sprintf("failed to create casbin enforcer: %v", err))
	}
    
}

到這裡準備工作基本完成,我們來寫一個通用的 登入,註冊,退出 業務吧

user_handler.go

package handler

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"
	"go-web-demo/component"
	"go-web-demo/handler/request"
	"go-web-demo/service"
	"net/http"
)

func Login(c *gin.Context) {
	loginRequest := &request.Login{}
	err := c.ShouldBindBodyWith(loginRequest, binding.JSON)
	if err != nil {
		panic(fmt.Errorf("request body bind error: %v", err))
	}
	token := service.Login(loginRequest)

	c.JSON(http.StatusOK, component.RestResponse{Code: 1, Data: token, Message: loginRequest.Username + " logged in successfully"})

}

func Logout(c *gin.Context) {
	token := c.Request.Header.Get("token")

	if token == "" {
		panic(fmt.Errorf("token error: token is nil"))
	}

	bytes, err := component.GlobalCache.Get(token)

	if err != nil {
		panic(fmt.Errorf("token error: failed to get username: %v", err))
	}

	username := string(bytes)
	// Authentication

	// Delete store current subject in cache
	err = component.GlobalCache.Delete(token)
	if err != nil {
		panic(fmt.Errorf("failed to delete current subject in cache: %w", err))
	}

	c.JSON(http.StatusOK, component.RestResponse{Code: 1, Data: token, Message: username + " logout in successfully"})
}

func Register(c *gin.Context) {
	register := &request.Register{}
	err := c.ShouldBindBodyWith(register, binding.JSON)
	if err != nil {
		c.JSON(400, component.RestResponse{Code: -1, Message: " bind error"})
		return
	}

	service.Register(register)

	c.JSON(http.StatusOK, component.RestResponse{Code: 1, Data: nil, Message: "register successfully"})
}

service.user.go

這裡要注意 註冊的時候我們做了兩個操作,註冊到user表,把policy寫入到casbin_rule表,要保證他們要同時成功,所以要用事務

func Login(loginRequest *request.Login) string {
	password := loginRequest.Password
	username := loginRequest.Username

	// Authentication
	user := dao.GetByUsername(username)
	if password != user.Password {
		panic(fmt.Errorf(username + " logged error : password error"))
	}

	// Generate random uuid token
	u, err := uuid.NewRandom()
	if err != nil {
		panic(fmt.Errorf("failed to generate UUID: %w", err))
	}
	// Sprintf token
	token := fmt.Sprintf("%s-%s", u.String(), "token")
	// Store current subject in cache
	err = component.GlobalCache.Set(token, []byte(username))
	if err != nil {
		panic(fmt.Errorf("failed to store current subject in cache: %w", err))
	}
	// Send cache key back to client cookie
	//c.SetCookie("current_subject", token, 30*60, "/resource", "", false, true)
	return token
}

func Register(register *request.Register) {
	var err error
	e := component.Enforcer
	err = e.GetAdapter().(*gormadapter.Adapter).Transaction(e, func(copyEnforcer casbin.IEnforcer) error {
		// Insert to table
		db := copyEnforcer.GetAdapter().(*gormadapter.Adapter).GetDb()
		res := db.Exec("insert into user (username,password) values(?,?)", register.Username, register.Password)

		//User has Username and Password
		//res := db.Table("user").Create(&User{
		//	Username: register.Username,
		//	Password: register.Password,
		//})

		if err != nil || res.RowsAffected < 1 {
			return fmt.Errorf("insert error: %w", err)
		}

		_, err = copyEnforcer.AddRoleForUser(register.Username, "role::user")
		if err != nil {
			return fmt.Errorf("add plocy error: %w", err)
		}
		return nil
	})

	if err != nil {
		panic(err)
	}

}

dao.user.go 對資料庫的操作

package dao

import "go-web-demo/component"

type User struct {
	Id       int64 `gorm:"primaryKey"`
	Username string
	Password string
	Email    string
	Phone    string
}

func (u *User) TableName() string {
	return "user"
}

func GetByUsername(username string) *User {
	res := new(User)
	component.DB.Model(&User{}).Where("username = ?", username).First(res)
	return res
}

func Insert(username string, password string) (int64, error, int64) {
	user := &User{Username: username, Password: password}
	res := component.DB.Create(&user)

	return user.Id, res.Error, res.RowsAffected
}

最後一步,啟動web服務,設定路由

package main

import (
	"fmt"
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"go-web-demo/component"
	"go-web-demo/config"
	"go-web-demo/handler"
	"go-web-demo/middleware"
	"log"
)

var (
	router *gin.Engine
)

func init() {
	//Initialize components from config yaml: mysql locaCache casbin
	component.CreateByConfig()

	// Initialize gin engine
	router = gin.Default()

	// Initialize gin middleware
	corsConfig := cors.DefaultConfig()
	corsConfig.AllowAllOrigins = true
	corsConfig.AllowCredentials = true
	router.Use(cors.New(corsConfig))
	router.Use(middleware.Recover)

	// Initialize gin router
	user := router.Group("/user")
	{
		user.POST("/login", handler.Login)
		user.POST("/logout", handler.Logout)
		user.POST("/register", handler.Register)
	}

	resource := router.Group("/api")
	{
		resource.Use(middleware.DefaultAuthorize("user::resource", "read-write"))
		resource.GET("/resource", handler.ReadResource)
		resource.POST("/resource", handler.WriteResource)
	}

}

func main() {
	// Start
	port := config.Reader.Server.Port
	err := router.Run(":" + port)
	if err != nil {
		panic(fmt.Sprintf("failed to start gin engine: %v", err))
	}
	log.Println("application is now running...")
}

表結構和相關測試資料

CREATE DATABASE /*!32312 IF NOT EXISTS*/`casbin_demo` /*!40100 DEFAULT CHARACTER SET utf8 */;

USE `casbin_demo`;

/*Table structure for table `casbin_rule` */

DROP TABLE IF EXISTS `casbin_rule`;

CREATE TABLE `casbin_rule` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `ptype` varchar(100) NOT NULL,
  `v0` varchar(100) DEFAULT NULL,
  `v1` varchar(100) DEFAULT NULL,
  `v2` varchar(100) DEFAULT NULL,
  `v3` varchar(100) DEFAULT NULL,
  `v4` varchar(100) DEFAULT NULL,
  `v5` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_casbin_rule` (`v0`,`v1`,`v2`,`v3`,`v4`,`v5`)
) ENGINE=InnoDB AUTO_INCREMENT=64 DEFAULT CHARSET=utf8;

/*Data for the table `casbin_rule` */

insert  into `casbin_rule`(`id`,`ptype`,`v0`,`v1`,`v2`,`v3`,`v4`,`v5`) values 

(3,'p','role::admin','admin::resource','read-write','','',''),

(5,'p','role::user','user::resource','read-write','','',''),

(57,'g','test1','role::user','','','',''),

(59,'g','role::admin','role::user','','','',''),

(63,'g','test2','role::admin',NULL,NULL,NULL,NULL);

/*Table structure for table `user` */

DROP TABLE IF EXISTS `user`;

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL,
  `password` varchar(50) DEFAULT NULL,
  `email` varchar(50) DEFAULT NULL,
  `phone` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8;

/*Data for the table `user` */

insert  into `user`(`id`,`username`,`password`,`email`,`phone`) values 

(36,'test1','123',NULL,NULL),

(38,'test2','123',NULL,NULL);