Golang基于JWT與Casbin身份驗(yàn)證授權(quán)實(shí)例詳解
JWT
JSON Web Toekn(JWT)是一個(gè)開放標(biāo)準(zhǔn)RFC 7519,以JSON的方式進(jìn)行通信,是目前最流行的一種身份驗(yàn)證方式之一。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
下圖是通過JWT.io解碼,查看JWT token的組成

可以看出JWT是由下面三個(gè)部分組成的:
- 頭部(Header)
- 載荷(Payload)
- 簽名(Signature)
Header
Header是Token的構(gòu)成的第一部分,包含了Token類型、Token使用的加密算法(加密算法可以是HMAC, SHA256或者是RSA等)。在某些場(chǎng)景下,可以使用kid字段,用來標(biāo)識(shí)一個(gè)密鑰的ID
Payload
Payload是token的第二部分,由JWT標(biāo)準(zhǔn)中注冊(cè)的、公共的、私有的聲明三部分組成。Payload通常包含一些用戶的聲明信息,比如簽發(fā)者、過期時(shí)間、簽發(fā)時(shí)間等。其中最常見的是issuer, expiration, subject。
- issuer被用來標(biāo)識(shí)token的頒發(fā)人
- expiration是token的過期時(shí)間
- subject被用來標(biāo)識(shí)token主體部分
Signature
Signature是由頭部和載荷加密后連接起來的,程序通過驗(yàn)證Signature是否合法來決定認(rèn)證是否通過
JWT的優(yōu)勢(shì)
- 體積小。JWT是采用JSON進(jìn)行通信的,JSON比XML更加簡潔,因此在對(duì)其編碼時(shí),JWT的體積比SAML更小(SAML是一種基于XML的開放標(biāo)準(zhǔn),用在身份提供者和服務(wù)提供者之間交換身份驗(yàn)證和授權(quán)的數(shù)據(jù),SAML的一個(gè)重要的應(yīng)用就是基于Web的單點(diǎn)登錄)
- 更加安全。JWT能夠使用公鑰或者私鑰對(duì)證書進(jìn)行加密或解密,雖然SAML也可以使用JWT等公鑰或私鑰進(jìn)行加密或解密,但是與JSON相比,使用XML數(shù)字簽名容易引進(jìn)比較晦澀的安全漏洞
- 更加通用。JSON可以轉(zhuǎn)換成很多語言的對(duì)象方式,而XML沒有一種可以轉(zhuǎn)為對(duì)象的映射
- 更容易處理。不管是在PC端還是在移動(dòng)端,JSON都能夠很好的進(jìn)行通信
JWT的使用場(chǎng)景
- 身份驗(yàn)證。
- 授權(quán)
- 信息交換
需要注意的是不要將敏感信息存在Token里面!!!
Casbin
Casbin是一個(gè)強(qiáng)大的、高效的、開源的權(quán)限訪問控制庫,它提供了多種權(quán)限控制訪問模型,比如ACL(權(quán)限控制列表)、RBAC(基于角色的訪問控制)、ABAC(基于屬性的權(quán)限驗(yàn)證)等。除此之外Casbin還支持多種編程語言

Casbin可以做什么
- 通過經(jīng)典的
{subject, object, action}或者自定義的模式執(zhí)行想要的策略,同時(shí)支持allow和deny兩種授權(quán)方式 - 處理控制訪問存儲(chǔ)和權(quán)限
- 管理用戶-角色-資源權(quán)限控制訪問映射(RBAC)
- 支持超級(jí)管理員授權(quán)方式
- 可以使用內(nèi)置的函數(shù)配置訪問規(guī)則
Casbin不可以做什么
- 使用用戶名或密碼登錄的身份驗(yàn)證
- 管理用戶或者角色列表,這些由系統(tǒng)本身管理更加方便,casbin主要是用來作為用戶-角色的一種權(quán)限訪問控制映射
Casbin的工作原理
在Casbin中,訪問控制模型被抽象為PERM(Policy, Effect, Request, Matcher)的一個(gè)文件
- Request
定義請(qǐng)求參數(shù)。基本請(qǐng)求時(shí)一個(gè)元組對(duì)象,至少需要主題(訪問實(shí)體), 對(duì)象(訪問資源), 動(dòng)作(訪問方式),例如r={sub, obj, act},它實(shí)際定義了我們應(yīng)該提供訪問控制匹配功能的參數(shù)名稱和順序
- Policy
定義訪問策略模式,例如p={sub, obj, act}或p={sub, obj, act, eff}, 它定義字段的名稱和順序
- Matcher
匹配請(qǐng)求和策略的規(guī)則,例如m = r.sub == p.sub && r.obj == p.obj && r.act == p.act,它的意思是如果請(qǐng)求的參數(shù)被匹配,那么結(jié)果就會(huì)被返回
- Effect
匹配后的結(jié)果會(huì)儲(chǔ)存于Effect當(dāng)中,可以對(duì)匹配結(jié)果再次做出邏輯判斷,例如e = some (where (p.eft == allow))
Casbin中最基本的model是ACL,下面是ACL的model配置
[request_definition] r = sub, obj, act # Policy definition [policy_definition] p = sub, obj, act # Policy effect [policy_effect] e = some(where (p.eft == allow)) # Matchers [matchers] m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
實(shí)踐
編寫一個(gè)簡單的TODO RESTful API。
創(chuàng)建一個(gè)simple-jwt-auth的目錄,然后通過go mod管理依賴 go mod init simple-jwt-auth 建立的目錄結(jié)構(gòu)如下:

在model定義User和Todo的結(jié)構(gòu)體
// models/model.go
type User struct {
ID string `json:"id"`
UserName string `json:"username"`
Password string `json:"password"`
}
type Todo struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Body string `json:"body"`
}
// SetPassword sets a new password stored as hash.
func (m *User) SetPassword(password string) error {
if len(password) < 6 {
return fmt.Errorf("new password for %s must be at least 6 characters", m.UserName)
}
m.Password = password
return nil
}
// InvalidPassword returns true if the given password does not match the hash.
func (m *User) InvalidPassword(password string) bool {
if password == "" {
return true
}
if m.Password != password {
return true
}
return false
}
登錄接口請(qǐng)求
當(dāng)用戶通過用戶名和密碼等信息登錄系統(tǒng)服務(wù)時(shí),需要驗(yàn)證是否已注冊(cè)、密碼是否正確等,然后返回信息, 下面在api層實(shí)現(xiàn)Login的接口:
// api/auth_api.go
func Login(c *gin.Context) {
var u models.User
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(http.StatusUnprocessableEntity, "Invalid json provided")
return
}
//find user with username
user, err := models.UserRepo.FindByID(1)
//compare the user from the request, with the one we defined:
if user.UserName != u.UserName || user.Password != u.Password {
c.JSON(http.StatusUnauthorized, "Please provide valid login details")
return
}
c.JSON(http.StatusOK, "Login successfully")
}
func Logout(c *gin.Context) {
c.JSON(http.StatusOK, "Successfully logged out")
}
在真實(shí)的項(xiàng)目中,數(shù)據(jù)都是存在數(shù)據(jù)庫中。在該教程中,為了方便,創(chuàng)建一個(gè)mock文件user_repository.go
// models/user_repository.go
var us = []User{
{
ID: "2",
UserName: "users",
Password: "pass",
}, {
ID: "3",
UserName: "username",
Password: "password",
},
}
var UserRepo = UserRepository{
Users: us,
}
type UserRepository struct {
Users []User
}
func (r *UserRepository) FindAll() ([]User, error) {
return r.Users, nil
}
func (r *UserRepository) FindByID(id int) (User, error) {
for _, v := range r.Users {
uid, err := strconv.Atoi(v.ID)
if err != nil {
return User{}, err
}
if uid == int(id) {
return v, nil
}
}
return User{}, errors.New("Not found")
}
func (r *UserRepository) Save(user User) (User, error) {
r.Users = append(r.Users, user)
return user, nil
}
func (r *UserRepository) Delete(user User) {
id := -1
for i, v := range r.Users {
if v.ID == user.ID {
id = i
break
}
}
if id == -1 {
log.Fatal("Not found user ")
return
}
r.Users[id] = r.Users[len(r.Users)-1] // Copy last element to index i.
r.Users[len(r.Users)-1] = User{} // Erase last element (write zero value).
r.Users = r.Users[:len(r.Users)-1] // Truncate slice.
return
}
為了不讓Login函數(shù)變得臃腫,生成token的邏輯放在auth目錄中, 下面實(shí)現(xiàn)token驗(yàn)證邏輯
Token實(shí)現(xiàn)
用JWT實(shí)現(xiàn)的系統(tǒng)中,用戶登錄后,系統(tǒng)會(huì)生成并返回一個(gè)token給用戶,下次請(qǐng)求時(shí)將會(huì)帶上該token進(jìn)行身份驗(yàn)證。token有以下問題需要處理:
- 用戶退出登錄的時(shí)候,需要使token失效
token有可能被黑客劫持和使用- 當(dāng)
token過期后需要用戶重新登錄,體驗(yàn)不友好
上面的問題可以通過以下兩種方式解決:
- 使用Redis存儲(chǔ)token的信息。當(dāng)用戶退出時(shí),使token失效, 這在一定程度上提高的安全性
- 在token過期的時(shí)候,使用刷新token的方式重新生成一個(gè)token, 不用用戶退出登錄,提高用戶體驗(yàn)
使用Redis存儲(chǔ)Token信息
使用uuid作為redis中的key, token信息作為value, 下面定義TokenManager結(jié)構(gòu)體,通過接口的方式實(shí)現(xiàn)token
type TokenManager struct{}
func NewTokenService() *TokenManager {
return &TokenManager{}
}
type TokenInterface interface {
CreateToken(userId, userName string) (*TokenDetails, error)
ExtractTokenMetadata(*http.Request) (*AccessDetails, error)
}
//Token implements the TokenInterface
var _ TokenInterface = &TokenManager{}
func (t *TokenManager) CreateToken(userId, userName string) (*TokenDetails, error) {
td := &TokenDetails{}
td.AtExpires = time.Now().Add(time.Minute * 30).Unix() //expires after 30 min
td.TokenUuid = uuid.NewV4().String()
td.RtExpires = time.Now().Add(time.Hour * 24 * 7).Unix()
td.RefreshUuid = td.TokenUuid + "++" + userId
var err error
//Creating Access Token
atClaims := jwt.MapClaims{}
atClaims["access_uuid"] = td.TokenUuid
atClaims["user_id"] = userId
atClaims["user_name"] = userName
atClaims["exp"] = td.AtExpires
at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims)
td.AccessToken, err = at.SignedString([]byte(os.Getenv("ACCESS_SECRET")))
if err != nil {
return nil, err
}
//Creating Refresh Token
td.RtExpires = time.Now().Add(time.Hour * 24 * 7).Unix()
td.RefreshUuid = td.TokenUuid + "++" + userId
rtClaims := jwt.MapClaims{}
rtClaims["refresh_uuid"] = td.RefreshUuid
rtClaims["user_id"] = userId
rtClaims["user_name"] = userName
rtClaims["exp"] = td.RtExpires
rt := jwt.NewWithClaims(jwt.SigningMethodHS256, rtClaims)
td.RefreshToken, err = rt.SignedString([]byte(os.Getenv("REFRESH_SECRET")))
if err != nil {
return nil, err
}
return td, nil
}
func (t *TokenManager) ExtractTokenMetadata(r *http.Request) (*AccessDetails, error) {
token, err := VerifyToken(r)
if err != nil {
return nil, err
}
acc, err := Extract(token)
if err != nil {
return nil, err
}
return acc, nil
}
func TokenValid(r *http.Request) error {
token, err := VerifyToken(r)
if err != nil {
return err
}
if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid {
return err
}
return nil
}
func VerifyToken(r *http.Request) (*jwt.Token, error) {
tokenString := ExtractToken(r)
token, err := jwt.Parse(tokenString, 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(os.Getenv("ACCESS_SECRET")), nil
})
if err != nil {
return nil, err
}
return token, nil
}
//get the token from the request body
func ExtractToken(r *http.Request) string {
bearToken := r.Header.Get("Authorization")
strArr := strings.Split(bearToken, " ")
if len(strArr) == 2 {
return strArr[1]
}
return ""
}
func Extract(token *jwt.Token) (*AccessDetails, error) {
claims, ok := token.Claims.(jwt.MapClaims)
if ok && token.Valid {
accessUuid, ok := claims["access_uuid"].(string)
userId, userOk := claims["user_id"].(string)
userName, userNameOk := claims["user_name"].(string)
if ok == false || userOk == false || userNameOk == false {
return nil, errors.New("unauthorized")
} else {
return &AccessDetails{
TokenUuid: accessUuid,
UserId: userId,
UserName: userName,
}, nil
}
}
return nil, errors.New("something went wrong")
}
func ExtractTokenMetadata(r *http.Request) (*AccessDetails, error) {
token, err := VerifyToken(r)
if err != nil {
return nil, err
}
acc, err := Extract(token)
if err != nil {
return nil, err
}
return acc, nil
}
上面的代碼設(shè)置token的有效時(shí)間為30分鐘,30分鐘過后token將失效,用戶不能使用該token進(jìn)行正確驗(yàn)證。
另外,使用了從.env配置文件獲取的密鑰(ACCESS_SECRET)簽名。在真實(shí)的項(xiàng)目,不能在代碼中公開這個(gè)密鑰!!!
REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= ACCESS_SECRET=98hbun98hsdfsdwesdfs REFRESH_SECRET=786dfdbjhsbsdfsdfsdf PORT=8081
定義AuthInterface處理會(huì)話
package auth
import (
"errors"
"fmt"
"github.com/go-redis/redis/v7"
"time"
)
type AccessDetails struct {
TokenUuid string
UserId string
UserName string
}
type TokenDetails struct {
AccessToken string
RefreshToken string
TokenUuid string
RefreshUuid string
AtExpires int64
RtExpires int64
}
type AuthInterface interface {
CreateAuth(string, *TokenDetails) error
FetchAuth(string) (string, error)
DeleteRefresh(string) error
DeleteTokens(*AccessDetails) error
}
type RedisAuthService struct {
client *redis.Client
}
var _ AuthInterface = &RedisAuthService{}
func NewAuthService(client *redis.Client) *RedisAuthService {
return &RedisAuthService{client: client}
}
//Save token metadata to Redis
func (tk *RedisAuthService) CreateAuth(userId string, td *TokenDetails) error {
at := time.Unix(td.AtExpires, 0) //converting Unix to UTC(to Time object)
rt := time.Unix(td.RtExpires, 0)
now := time.Now()
atCreated, err := tk.client.Set(td.TokenUuid, userId, at.Sub(now)).Result()
if err != nil {
return err
}
rtCreated, err := tk.client.Set(td.RefreshUuid, userId, rt.Sub(now)).Result()
if err != nil {
return err
}
if atCreated == "0" || rtCreated == "0" {
return errors.New("no record inserted")
}
return nil
}
//Check the metadata saved
func (tk *RedisAuthService) FetchAuth(tokenUuid string) (string, error) {
userid, err := tk.client.Get(tokenUuid).Result()
if err != nil {
return "", err
}
return userid, nil
}
//Once a user row in the token table
func (tk *RedisAuthService) DeleteTokens(authD *AccessDetails) error {
//get the refresh uuid
refreshUuid := fmt.Sprintf("%s++%s", authD.TokenUuid, authD.UserId)
//delete access token
deletedAt, err := tk.client.Del(authD.TokenUuid).Result()
if err != nil {
return err
}
//delete refresh token
deletedRt, err := tk.client.Del(refreshUuid).Result()
if err != nil {
return err
}
//When the record is deleted, the return value is 1
if deletedAt != 1 || deletedRt != 1 {
return errors.New("something went wrong")
}
return nil
}
func (tk *RedisAuthService) DeleteRefresh(refreshUuid string) error {
//delete refresh token
deleted, err := tk.client.Del(refreshUuid).Result()
if err != nil || deleted == 0 {
return err
}
return nil
}
用Casbin做授權(quán)管理
在Casbin中,一個(gè)權(quán)限訪問控制模型的配置文件是基于PERM(Policy, Effect, Role, Matcher)的方式,因此當(dāng)要修改或升級(jí)權(quán)限的時(shí)候非常方便,只需要修改配置文件就行了。使用者可以自定義配置文件,例如定義RBAC或者ACL
最基本的也是最簡單的模型是ACL, 下面創(chuàng)建一個(gè)ACL的模型配置文件
[request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act [role_definition] g = _, _ [policy_effect] e = some(where (p.eft == allow)) [matchers] m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
Casbin 的權(quán)限是存儲(chǔ)在.csv文件中或者是SQL數(shù)據(jù)庫中, 在該教程是通過csv文件的方式存儲(chǔ)
p, user, resource, read p, username, resource, read p, admin, resource, read p, admin, resource, write g, alice, admin g, bob, user
上面權(quán)限的意思是:
- 所有的用戶可以讀數(shù)據(jù),但是不能寫
- 所有的admin用戶可以讀數(shù)據(jù),也可以寫數(shù)據(jù)
- alice是admin用戶,bob是普通用戶 因此Alice有控制整個(gè)系統(tǒng)數(shù)據(jù)的權(quán)限,而Bob只有讀的權(quán)限
實(shí)現(xiàn)Casbin的策略
首先,定義一個(gè)policies的中間件
import (
"fmt"
"github.com/casbin/casbin"
"github.com/casbin/casbin/persist"
"github.com/gin-gonic/gin"
"github.com/simple-jwt-auth/auth"
"log"
"net/http"
)
func TokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
err := auth.TokenValid(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, "unauthorized")
c.Abort()
return
}
c.Next()
}
}
// Authorize determines if current subject has been authorized to take an action on an object.
func Authorize(obj string, act string, adapter persist.Adapter) gin.HandlerFunc {
return func(c *gin.Context) {
err := auth.TokenValid(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, "user hasn't logged in yet")
c.Abort()
return
}
metadata, err := auth.ExtractTokenMetadata(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
// casbin enforces policy
ok, err := enforce(metadata.UserName, obj, act, adapter)
//ok, err := enforce(val.(string), obj, act, adapter)
if err != nil {
log.Println(err)
c.AbortWithStatusJSON(500, "error occurred when authorizing user")
return
}
if !ok {
c.AbortWithStatusJSON(403, "forbidden")
return
}
c.Next()
}
}
func enforce(sub string, obj string, act string, adapter persist.Adapter) (bool, error) {
enforcer := casbin.NewEnforcer("config/rbac_model.conf", adapter)
err := enforcer.LoadPolicy()
if err != nil {
return false, fmt.Errorf("failed to load policy from DB: %w", err)
}
ok := enforcer.Enforce(sub, obj, act)
return ok, nil
}
然后,修改上面的Login和Logout接口, 增加身份驗(yàn)證及授權(quán)信息
func Login(c *gin.Context) {
var u models.User
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(http.StatusUnprocessableEntity, "Invalid json provided")
return
}
//find user with username
user, err := models.UserRepo.FindByID(1)
//compare the user from the request, with the one we defined:
if user.UserName != u.UserName || user.Password != u.Password {
c.JSON(http.StatusUnauthorized, "Please provide valid login details")
return
}
ts, err := tokenManager.CreateToken(user.ID, user.UserName)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, err.Error())
return
}
save token to redis
saveErr := servers.HttpServer.RD.CreateAuth(user.ID, ts)
if saveErr != nil {
c.JSON(http.StatusUnprocessableEntity, saveErr.Error())
}
tokens := map[string]string{
"access_token": ts.AccessToken,
"refresh_token": ts.RefreshToken,
}
c.JSON(http.StatusOK, tokens)
}
func Logout(c *gin.Context) {
//If metadata is passed and the tokens valid, delete them from the redis store
metadata, _ := tokenManager.ExtractTokenMetadata(c.Request)
if metadata != nil {
deleteErr := servers.HttpServer.RD.DeleteTokens(metadata)
if deleteErr != nil {
c.JSON(http.StatusBadRequest, deleteErr.Error())
return
}
}
c.JSON(http.StatusOK, "Successfully logged out")
}
創(chuàng)建Todo
定義Todo的結(jié)構(gòu)體
type Todo struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Body string `json:"body"`
}
創(chuàng)建Todo的接口
package api
import (
"github.com/gin-gonic/gin"
"github.com/simple-jwt-auth/auth"
"github.com/simple-jwt-auth/models"
"net/http"
)
func CreateTodo(c *gin.Context) {
var td models.Todo
if err := c.ShouldBindJSON(&td); err != nil {
c.JSON(http.StatusUnprocessableEntity, "invalid json")
return
}
metadata, err := auth.ExtractTokenMetadata(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
td.UserID = metadata.UserId
//you can proceed to save the to a database
c.JSON(http.StatusCreated, td)
}
func GetTodo(c *gin.Context) {
metadata, err := auth.ExtractTokenMetadata(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
userId := metadata.UserId
c.JSON(http.StatusOK, models.Todo{
UserID: userId,
Title: "Return todo",
Body: "Return todo for testing",
})
}
注冊(cè)路由
func (s *Server) InitializeRoutes() {
s.Router.POST("/login", api.Login)
authorized := s.Router.Group("/")
authorized.Use(gin.Logger())
authorized.Use(gin.Recovery())
authorized.Use(middleware.TokenAuthMiddleware())
{
authorized.POST("/api/todo", middleware.Authorize("resource", "write", s.FileAdapter), api.CreateTodo)
authorized.GET("/api/todo", middleware.Authorize("resource", "read", s.FileAdapter), api.GetTodo)
authorized.POST("/logout", api.Logout)
authorized.POST("/refresh", api.Refresh)
}
}
最后在main.go文件中調(diào)用server層的Run方法即可運(yùn)行
package main
import (
"github.com/joho/godotenv"
"github.com/simple-jwt-auth/servers"
"log"
)
func init() {
if err := godotenv.Load(); err != nil {
log.Print("No .env file found")
}
}
func main() {
servers.Run()
log.Println("Server exiting")
}
結(jié)果如下:

原作者倉庫地址github
以上就是Golang基于JWT與Casbin身份驗(yàn)證授權(quán)實(shí)例詳解的詳細(xì)內(nèi)容,更多關(guān)于Go JWT Casbin身份驗(yàn)證授權(quán)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Web框架Gin中間件實(shí)現(xiàn)原理步驟解析
這篇文章主要為大家介紹了Web框架Gin中間件實(shí)現(xiàn)原理步驟解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-10-10
Go語言基本的語法和內(nèi)置數(shù)據(jù)類型初探
這篇文章主要介紹了Go語言基本的語法和內(nèi)置數(shù)據(jù)類型,是golang入門學(xué)習(xí)中的基礎(chǔ)知識(shí),需要的朋友可以參考下2015-10-10
golang 如何實(shí)現(xiàn)HTTP代理和反向代理
這篇文章主要介紹了golang 實(shí)現(xiàn)HTTP代理和反向代理的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2021-05-05
xorm根據(jù)數(shù)據(jù)庫生成go model文件的操作
這篇文章主要介紹了xorm根據(jù)數(shù)據(jù)庫生成go model文件的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2020-12-12

